From d4682dd9ebd81bc3a9a2856b7b78c00233df8d04 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:20:43 +0300 Subject: [PATCH 01/61] Fix JsonSession.toggle_ir raising ValueError on every call control() returns None (it already awaits the ACK internally), so the subsequent `await self.wait_ack(idx)` passed idx=None and tripped the "Need to provide numeric command index" guard, surfacing an error to the caller even though the IR command was sent. Drop the redundant wait. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index e00765d..73bc07e 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -520,8 +520,9 @@ async def toggle_whitelight(self, value, **kwargs): async def toggle_ir(self, value): logger.info('%s: toggle IR = %s', self.dev.dev_id, value) - idx = await self.control(icut=1 if value else 0) - await self.wait_ack(idx) + # control() already waits for the ACK; it returns None, so the previous + # `await self.wait_ack(idx)` raised ValueError on every call. + await self.control(icut=1 if value else 0) async def rotate_start(self, value): logger.info('%s: rotate_start %s', self.dev.dev_id, value) From 93ecf4857e9236ccfa0ea8fcee664bf94a4c9a2b Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:21:03 +0300 Subject: [PATCH 02/61] Make Session.stop() idempotent stop() raised RuntimeError whenever state != CONNECTED, but it is called from several paths (_on_device_lost, Device.close, the CLI shutdown loop) and can run twice or on a session that never finished connecting. A second call then aborted the surrounding cleanup. Return early when already disconnected and guard each task/transport before touching it. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 73bc07e..32fc95b 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -344,15 +344,21 @@ def _on_device_lost(self): self.on_disconnect(self.dev) def stop(self): - if self.state != State.CONNECTED: - raise RuntimeError('Session is not started') + if self.state == State.DISCONNECTED: + # Already stopped: stop() is reachable from _on_device_lost(), + # Device.close() and the CLI shutdown loop, so it must be idempotent. + return logger.info('Stopping task for %s', self.dev.dev_id) self.device_is_ready.set() - self.process_packet_task.cancel() - self.process_video_task.cancel() - self.main_task.cancel() - self.transport.close() - self.transport = None + if self.process_packet_task: + self.process_packet_task.cancel() + if self.process_video_task: + self.process_video_task.cancel() + if self.main_task: + self.main_task.cancel() + if self.transport: + self.transport.close() + self.transport = None self.state = State.DISCONNECTED async def reboot(self): From 5905408c015a299a65d7f3694f5e4704e29a49c2 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:21:19 +0300 Subject: [PATCH 03/61] Don't swallow CancelledError in MJPEG stream handler `return response` inside a `finally` suppressed every in-flight exception, including the asyncio.CancelledError raised when the client disconnects. That defeated task cancellation and kept the streaming coroutine alive. Let the loop exit normally and return afterwards so cancellation propagates. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/http_server.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/aiopppp/http_server.py b/aiopppp/http_server.py index ce83d02..97aacdf 100644 --- a/aiopppp/http_server.py +++ b/aiopppp/http_server.py @@ -139,20 +139,18 @@ async def stream_video(request): frame_buffer = session.frame_buffer - try: - while True: - frame = await frame_buffer.get() - header = f'--{boundary}\r\n'.encode() - header += b'Content-Length: %d\r\n' % len(frame.data) - header += b'Content-Type: image/jpeg\r\n\r\n' - try: - await response.write(header) - await response.write(frame.data) - except ConnectionResetError: - logger.warning('Connection reset') - break - finally: - return response + while True: + frame = await frame_buffer.get() + header = f'--{boundary}\r\n'.encode() + header += b'Content-Length: %d\r\n' % len(frame.data) + header += b'Content-Type: image/jpeg\r\n\r\n' + try: + await response.write(header) + await response.write(frame.data) + except ConnectionResetError: + logger.warning('Connection reset') + break + return response async def start_web_server(port=4000): From 45ed578fd79c87696515a5b6c83bbd4baf54c19f Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:21:43 +0300 Subject: [PATCH 04/61] Wrap outgoing command index at 16 bits The command index is packed into a 16-bit wire field and DRW ACKs only carry 16 bits. Using an unbounded counter meant that after 65536 commands sends raised struct.error and, before that, ACK matching could never line up. Mask the counter to 0xFFFF so it wraps like the protocol expects. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 32fc95b..8bde89c 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -395,7 +395,9 @@ async def send_command(self, cmd, *, with_response=False, **kwargs): 'cmd': cmd.value, } pkt_idx = self.outgoing_command_idx - self.outgoing_command_idx += 1 + # The index is sent as a 16-bit field and ACKs only echo 16 bits, so it + # must wrap; otherwise sends raise struct.error and ACK matching breaks. + self.outgoing_command_idx = (self.outgoing_command_idx + 1) & 0xFFFF pkt = JsonCmdPkt(pkt_idx, {**data, **kwargs, **self.get_common_data()}) if with_response: self.cmd_waiters[cmd.value] = asyncio.Future() @@ -639,7 +641,9 @@ async def handle_incoming_command_packet(self, drw_pkt): async def send_command(self, cmd, cmd_payload=b'', *, with_response=False, **kwargs): pkt_idx = self.outgoing_command_idx - self.outgoing_command_idx += 1 + # The index is sent as a 16-bit field and ACKs only echo 16 bits, so it + # must wrap; otherwise sends raise struct.error and ACK matching breaks. + self.outgoing_command_idx = (self.outgoing_command_idx + 1) & 0xFFFF pkt = BinaryCmdPkt( pkt_idx, cmd, From efff761da5e5a5256839c6da371126e0cc90c0aa Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:22:22 +0300 Subject: [PATCH 05/61] Import VideoRotate so set_video_param('rotate', ...) works _build_video_param resolves the enum class via globals()['VideoRotate'], but VideoRotate was never imported into session.py, so the web-UI rotate control raised KeyError. Add it to the const import. Note: JsonSession still does not implement set_video_param; the JSON video-parameter controls remain unsupported (separate follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiopppp/session.py b/aiopppp/session.py index 8bde89c..23da7d7 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -15,6 +15,7 @@ PtzParamType, VideoParamType, VideoResolution, + VideoRotate, ) from .encrypt import ENC_METHODS from .exceptions import AuthError, CommandResultError From 8497c6ed81a307942bcc73284f46af9e458344db Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:22:46 +0300 Subject: [PATCH 06/61] Don't swallow CancelledError in find_device The discovery-task cleanup belongs in `finally`, but the `return`/`raise` that decides the result was inside it too, so a CancelledError from the outer await was suppressed (Python even emitted a SyntaxWarning). Keep only the cleanup in `finally` and resolve the result afterwards. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/device.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aiopppp/device.py b/aiopppp/device.py index a5ef500..fbb0abf 100644 --- a/aiopppp/device.py +++ b/aiopppp/device.py @@ -32,9 +32,10 @@ def on_device_connect(device): task.cancel() with contextlib.suppress(asyncio.CancelledError): await task - if cam_device_fut.done(): - return cam_device_fut.result() - raise TimeoutError("Timeout connecting to the camera") + + if cam_device_fut.done(): + return cam_device_fut.result() + raise TimeoutError("Timeout connecting to the camera") class Device: From 8c80ea0639ff67cdf56f258e4466d557133c03da Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:23:06 +0300 Subject: [PATCH 07/61] Guard new-device future against double/None set_result on_device_found/on_device_lost called set_result() unconditionally on the shared new_device_fut. It is None before the main loop creates it and is recreated each iteration, so a second discovery callback (or one firing before the loop started) raised InvalidStateError/AttributeError. Funnel both through a guarded helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/__main__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/aiopppp/__main__.py b/aiopppp/__main__.py index 6e91220..01e85a6 100644 --- a/aiopppp/__main__.py +++ b/aiopppp/__main__.py @@ -18,19 +18,28 @@ def get_new_device_fut(): return new_device_fut + +def notify_new_device(): + # The future is recreated on every main-loop iteration and is None before + # the loop starts, so guard against missing/already-resolved futures. + fut = get_new_device_fut() + if fut is not None and not fut.done(): + fut.set_result(None) + + def on_device_found(device, login, password): session = make_session(device, on_device_lost=on_device_lost, login=login, password=password) SESSIONS[device.dev_id.dev_id] = session session.start() tasks[device.dev_id.dev_id] = session.running_tasks() - get_new_device_fut().set_result(None) + notify_new_device() def on_device_lost(device): logger.warning('Device %s lost', device.dev_id) SESSIONS.pop(device.dev_id.dev_id, None) tasks.pop(device.dev_id.dev_id, None) - get_new_device_fut().set_result(None) + notify_new_device() async def amain(remote_addr, local_port, username, password): From 428608bf6eef6791138ddb2011a79dabbcf3c9ad Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:53:36 +0300 Subject: [PATCH 08/61] Vectorize XOR1 decode (~28x faster on the video hot path) Every incoming UDP packet on XOR1 cameras was decoded with a per-byte Python loop, the dominant CPU cost while streaming. The keystream byte at position i depends only on the previous ciphertext byte, and for decode all ciphertext is known up front, so the keystream can be produced with a single bytes.translate() over a precomputed 256-entry table and applied with one big-integer XOR. Output is bit-for-bit identical to the previous implementation (verified across random and edge-case inputs); decode of a 1400-byte packet is ~28x faster. Encode stays a tight loop (sequential, and only used for small outgoing packets) but reuses the same precomputed table. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/encrypt.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/aiopppp/encrypt.py b/aiopppp/encrypt.py index 73f2811..e094f09 100644 --- a/aiopppp/encrypt.py +++ b/aiopppp/encrypt.py @@ -19,25 +19,34 @@ XOR1_ENC_KEY = (0x69, 0x97, 0xcc, 0x19) +# Keystream byte as a function of the previous *ciphertext* byte. The original +# code recomputed `(XOR1_ENC_KEY[prev & 3] + prev) & 0xff` per byte; precompute +# it once as a 256-entry table that doubles as a bytes.translate() map. +XOR1_KEYSTREAM = bytes( + XOR1_KEY_TABLE[(XOR1_ENC_KEY[prev & 0x03] + prev) & 0xff] for prev in range(256) +) + def xor1_decode(data): - prev_byte = 0 - buf = bytearray([0] * len(data)) - for i in range(len(data)): - index = (XOR1_ENC_KEY[prev_byte & 0x03] + prev_byte) & 0xff - orig_byte = data[i] - buf[i] = orig_byte ^ XOR1_KEY_TABLE[index] - prev_byte = orig_byte - return bytes(buf) + # The keystream at position i depends only on the previous ciphertext byte, + # and for decode every ciphertext byte is the input โ€” so the keystream is + # fully determined up front. data[0] uses prev_byte = 0. + n = len(data) + if not n: + return b'' + keystream = XOR1_KEYSTREAM[0:1] + data[:-1].translate(XOR1_KEYSTREAM) + return (int.from_bytes(data, 'big') ^ int.from_bytes(keystream, 'big')).to_bytes(n, 'big') def xor1_encode(data): + # Encode is inherently sequential (each keystream byte depends on the byte + # just produced), but it only runs on small outgoing packets, not video. + buf = bytearray(len(data)) prev_byte = 0 - buf = bytearray([0] * len(data)) - for i in range(len(data)): - index = (XOR1_ENC_KEY[prev_byte & 0x03] + prev_byte) & 0xff - buf[i] = data[i] ^ XOR1_KEY_TABLE[index] - prev_byte = buf[i] + keystream = XOR1_KEYSTREAM + for i, b in enumerate(data): + prev_byte = b ^ keystream[prev_byte] + buf[i] = prev_byte return bytes(buf) From bd5fd40620e705e39bcb0c26c85ef333ee6d7f7c Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:54:42 +0300 Subject: [PATCH 09/61] Bound video reassembly buffers and cut per-chunk work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process_video_frame ran on every received chunk and rebuilt the payload list plus a diagnostic completeness string each time, even while the frame was incomplete. It also only pruned old chunks when a frame *completed*, so a frame left permanently incomplete by packet loss made video_received and video_boundaries grow without bound (measured: 4509 retained chunks vs 18 after this change on a lossy stream). Now the completeness check stops at the first gap and the payload/debug string are built only when needed, and chunks/boundaries below the current frame start are dropped on every call. Only the last two boundaries are ever assembled, so this cannot change which frames get published โ€” verified bit-for-bit against the previous behavior across 200 randomized lossy streams. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 52 ++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 23da7d7..d453704 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -112,36 +112,34 @@ async def handle_incoming_video_packet(self, pkt_epoch, pkt): async def process_video_frame(self): if len(self.video_boundaries) <= 1: return - frame_starts = sorted(list(self.video_boundaries)) + frame_starts = sorted(self.video_boundaries) index = frame_starts[-2] last_index = frame_starts[-1] - if index == self.last_video_frame: - return - - complete = True - out = [] - completeness = '' - for i in range(index, last_index): - if self.video_received.get(i) is not None: - out.append(self.video_received[i]) - completeness += 'x' - else: - complete = False - completeness += '_' - logger.debug(f".. completeness: {completeness}") - - if complete: - self.last_video_frame = index - - await self.frame_buffer.publish(VideoFrame(idx=index, data=b''.join(out))) - - to_delete = [idx for idx in self.video_received.keys() if idx < index] - for idx in to_delete: - del self.video_received[idx] - to_delete = [idx for idx in self.video_boundaries if idx < index] - for idx in to_delete: - self.video_boundaries.remove(idx) + if index != self.last_video_frame: + # Cheap completeness check: stop at the first gap instead of building + # the payload (and the diagnostic string) on every incoming chunk. + complete = all(i in self.video_received for i in range(index, last_index)) + if logger.isEnabledFor(logging.DEBUG): + completeness = ''.join( + 'x' if i in self.video_received else '_' + for i in range(index, last_index) + ) + logger.debug(f".. completeness: {completeness}") + + if complete: + self.last_video_frame = index + data = b''.join(self.video_received[i] for i in range(index, last_index)) + await self.frame_buffer.publish(VideoFrame(idx=index, data=data)) + + # Only the last two boundaries are ever assembled, so chunks/boundaries + # below the current frame start can never be published. Drop them on + # every call (not just on completion) so a permanently incomplete frame + # from packet loss can't make these buffers grow without bound. + for idx in [i for i in self.video_received if i < index]: + del self.video_received[idx] + for idx in [i for i in self.video_boundaries if i < index]: + self.video_boundaries.remove(idx) class Session(PacketQueueMixin, VideoQueueMixin): From b33efd1fb64b7b3b5760683137d031a07c84fe1c Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:31:22 +0300 Subject: [PATCH 10/61] Bind discovery to an OS-assigned local port by default Discovery picked a random local port in 0x800-0xfff0 and bound it with no retry. On Windows that port can fall inside an OS-reserved exclusion range, so the bind fails with PermissionError (WinError 10013) and takes the whole discovery/server down. Bind to port 0 instead (honoring an explicit local_port when set) so the OS hands back a guaranteed-free port, and log the chosen port. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/discover.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/aiopppp/discover.py b/aiopppp/discover.py index de7845b..303e300 100644 --- a/aiopppp/discover.py +++ b/aiopppp/discover.py @@ -1,6 +1,5 @@ import asyncio import logging -from random import randint from .const import CAM_MAGIC, PacketType from .encrypt import ENC_METHODS @@ -83,9 +82,15 @@ def on_receive(self, data, addr, callback): async def discover(self, callback, period=10): assert period >= 1, 'need to wait for camera response more than 1 second' logger.info('Start discovery on %s:%d', self.remote_addr, self.remote_port) - initial_port = self.local_port or randint(0x800, 0xfff0) - self.transport = await create_udp_server(initial_port, lambda data, addr: self.on_receive(data, addr, callback)) + # Bind to the requested local port, or 0 to let the OS pick a free one. + # A hard-coded random port could land in an OS-reserved range and fail + # to bind (e.g. WinError 10013 on Windows); port 0 always succeeds. + self.transport = await create_udp_server( + self.local_port, lambda data, addr: self.on_receive(data, addr, callback), + ) + bound_port = self.transport.get_extra_info('sockname')[1] + logger.info('Discovery listening on local UDP port %d', bound_port) possible_discovery_packets = self.get_possible_discovery_packets() try: while True: From 1c3cfcc76e6e5432aaa3e2025e652187480a4f93 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:31:44 +0300 Subject: [PATCH 11/61] Wire --local-discovery-port through to Discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amain() created Discovery(remote_addr=...) without passing local_port, so the -dp/--local-discovery-port CLI flag was parsed but never applied โ€” the discovery socket always used the default. Pass local_port through so the flag actually pins the port. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopppp/__main__.py b/aiopppp/__main__.py index 01e85a6..1fe88e0 100644 --- a/aiopppp/__main__.py +++ b/aiopppp/__main__.py @@ -45,7 +45,7 @@ def on_device_lost(device): async def amain(remote_addr, local_port, username, password): global discovery global new_device_fut - discovery = Discovery(remote_addr=remote_addr) + discovery = Discovery(remote_addr=remote_addr, local_port=local_port) discovery_task = asyncio.create_task(discovery.discover(lambda d: on_device_found(d, username, password))) webserver_task = asyncio.create_task(start_web_server()) From f8d5c761b102e3550b449df649e4972224f70c0b Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:59:52 +0300 Subject: [PATCH 12/61] Add on_video_state_change callback for streaming state Expose an optional callback that fires whenever a session's video stream starts or stops, so consumers (e.g. the Home Assistant integration) can reflect the real streaming state instead of assuming it is always on. The callback receives the new is_video_requested value and fires on start_video, stop_video, and on session teardown (so "streaming" clears when the session ends for any reason, including a stalled-stream drop). Threaded through Session, make_session, and Device; defaults to None and is fully backward-compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/device.py | 7 ++++++- aiopppp/session.py | 26 +++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/aiopppp/device.py b/aiopppp/device.py index fbb0abf..1aab594 100644 --- a/aiopppp/device.py +++ b/aiopppp/device.py @@ -39,13 +39,17 @@ def on_device_connect(device): class Device: - def __init__(self, ip_address: str, username: str = '', password: str = ''): + def __init__(self, ip_address: str, username: str = '', password: str = '', + on_video_state_change=None): self.ip_address = ip_address self.descriptor: DeviceDescriptor | None = None self.properties: dict = {} self._session: Session | None = None self.username = username self.password = password + # Optional callback(is_streaming: bool) forwarded to the session, fired + # whenever video streaming starts or stops. + self.on_video_state_change = on_video_state_change self.enable_reconnect = False async def connect(self, timeout: int = 15): @@ -59,6 +63,7 @@ async def connect(self, timeout: int = 15): login=self.username, password=self.password, on_device_lost=lambda dev: self.on_device_lost(), + on_video_state_change=self.on_video_state_change, ) self._session.start() session_tasks = self._session.running_tasks() diff --git a/aiopppp/session.py b/aiopppp/session.py index d453704..522104e 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -143,7 +143,7 @@ async def process_video_frame(self): class Session(PacketQueueMixin, VideoQueueMixin): - def __init__(self, dev, on_disconnect, *args, **kwargs): + def __init__(self, dev, on_disconnect, *args, on_video_state_change=None, **kwargs): super().__init__(*args, **kwargs) self.state = State.DISCONNECTED @@ -157,6 +157,9 @@ def __init__(self, dev, on_disconnect, *args, **kwargs): self.last_alive_pkt_at = datetime.datetime.now() self.last_drw_pkt_at = datetime.datetime.now() self.on_disconnect = on_disconnect + # Called with the new is_video_requested value whenever streaming + # starts or stops (including when the session is torn down). + self.on_video_state_change = on_video_state_change self.main_task = None self.drw_waiters = {} self.cmd_waiters = {} @@ -165,6 +168,10 @@ def __init__(self, dev, on_disconnect, *args, **kwargs): def __str__(self): return f'Session({self.dev.dev_id}) ({self.state.name})' + def _notify_video_state(self): + if self.on_video_state_change: + self.on_video_state_change(self.is_video_requested) + async def create_udp(self): loop = asyncio.get_running_loop() transport, _ = await loop.create_datagram_endpoint( @@ -231,10 +238,12 @@ async def start_video(self): self.last_drw_pkt_at = datetime.datetime.now() await self._request_video(1) self.is_video_requested = True + self._notify_video_state() async def stop_video(self): if self.is_video_requested: self.is_video_requested = False + self._notify_video_state() self.video_stale_at = None self.video_received = {} self.video_boundaries = set() @@ -358,6 +367,10 @@ def stop(self): if self.transport: self.transport.close() self.transport = None + if self.is_video_requested: + # The session is going away, so streaming has effectively stopped. + self.is_video_requested = False + self._notify_video_state() self.state = State.DISCONNECTED async def reboot(self): @@ -808,7 +821,14 @@ async def get(self): def make_session(device: DeviceDescriptor, on_device_lost: Callable[[DeviceDescriptor], None], - login: str = '', password: str = '') -> Session: + login: str = '', password: str = '', + on_video_state_change: Callable[[bool], None] = None) -> Session: """Create a session for the camera.""" session_class = JsonSession if device.is_json else BinarySession - return session_class(device, on_disconnect=on_device_lost, login=login, password=password) + return session_class( + device, + on_disconnect=on_device_lost, + login=login, + password=password, + on_video_state_change=on_video_state_change, + ) From 2aa8e8b0c0e25d2ecb7fa6dffca2885847d5a554 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:30:34 +0300 Subject: [PATCH 13/61] Detect dead connections via a receive timeout Binary cameras had no dead-peer detection at all: BinarySession.loop_step only sent P2PAlive, the video-stale disconnect logic is JSON-only, and the P2PAliveAck was received but ignored. A camera that silently dropped left a zombie session that kept sending keepalives forever, was never reported as lost, and never reconnected. Track the time of the last datagram received from the camera (any packet -- video, P2PAlive, ACK -- counts as proof of life) and, in the base loop_step, tear the session down if nothing arrives for RECV_TIMEOUT_SEC (20s). This works for both protocols. Also return from JsonSession.loop_step after a video-stale disconnect so it no longer falls through to the base step on an already-closed transport. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 522104e..f41feaa 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -143,6 +143,12 @@ async def process_video_frame(self): class Session(PacketQueueMixin, VideoQueueMixin): + # If no packet arrives from the camera for this many seconds, treat the + # connection as dead and tear it down. Works for both JSON and binary + # cameras (binary has no other liveness check), and catches a silently + # dropped peer that would otherwise leave a zombie session. + RECV_TIMEOUT_SEC = 20 + def __init__(self, dev, on_disconnect, *args, on_video_state_change=None, **kwargs): super().__init__(*args, **kwargs) @@ -156,6 +162,7 @@ def __init__(self, dev, on_disconnect, *args, on_video_state_change=None, **kwar self.video_stale_at = None self.last_alive_pkt_at = datetime.datetime.now() self.last_drw_pkt_at = datetime.datetime.now() + self.last_recv_at = datetime.datetime.now() self.on_disconnect = on_disconnect # Called with the new is_video_requested value whenever streaming # starts or stops (including when the session is torn down). @@ -181,6 +188,9 @@ async def create_udp(self): return transport def on_receive(self, data): + # The transport is bound to the camera's address, so any datagram here + # is proof of life for the dead-connection check in loop_step(). + self.last_recv_at = datetime.datetime.now() decoded = ENC_METHODS[self.dev.encryption][0](data) pkt = parse_packet(decoded) # logger.debug(f"recv< {pkt} {pkt.get_payload()}") @@ -330,8 +340,17 @@ async def _run(self): async def loop_step(self): logger.debug(f"iterate in Session for {self.dev.dev_id}") - if (datetime.datetime.now() - self.last_alive_pkt_at).total_seconds() > 10: - self.last_alive_pkt_at = datetime.datetime.now() + now = datetime.datetime.now() + if (now - self.last_recv_at).total_seconds() > self.RECV_TIMEOUT_SEC: + logger.warning( + 'No packets from %s for %ds: connection is dead, disconnecting', + self.dev.dev_id, self.RECV_TIMEOUT_SEC, + ) + await self.send_close_pkt() + self._on_device_lost() + return + if (now - self.last_alive_pkt_at).total_seconds() > 10: + self.last_alive_pkt_at = now logger.info('Send P2PAlive') await self.send(make_p2palive_pkt()) @@ -523,6 +542,9 @@ async def loop_step(self): logger.warning('No video for 10 seconds. Disconnecting') await self.send_close_pkt() self._on_device_lost() + # Session is being torn down; don't fall through to the base + # loop_step (which would touch the now-closed transport). + return await super().loop_step() async def control(self, no_ack=False, **kwargs): From f573acae766ed4a3e307954864164c573514cce0 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:30:34 +0300 Subject: [PATCH 14/61] Don't recreate a session for an already-connected camera Discovery re-finds known cameras on every cycle and on_device_found created a fresh session each time, overwriting SESSIONS[dev_id] without stopping the previous session -- leaking its running tasks (each kept sending keepalives). Skip when a session for that dev_id already exists; a lost device is removed from SESSIONS first, so reconnect on the next discovery still works. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/__main__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/aiopppp/__main__.py b/aiopppp/__main__.py index 1fe88e0..b81f0ba 100644 --- a/aiopppp/__main__.py +++ b/aiopppp/__main__.py @@ -28,10 +28,15 @@ def notify_new_device(): def on_device_found(device, login, password): + dev_id = device.dev_id.dev_id + if dev_id in SESSIONS: + # Discovery re-finds already-connected cameras every cycle; creating a + # new session here would leak the previous one's running tasks. + return session = make_session(device, on_device_lost=on_device_lost, login=login, password=password) - SESSIONS[device.dev_id.dev_id] = session + SESSIONS[dev_id] = session session.start() - tasks[device.dev_id.dev_id] = session.running_tasks() + tasks[dev_id] = session.running_tasks() notify_new_device() From 1a23481cae54a9d263e6f5d079de5596b6b25bf0 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:49:07 +0300 Subject: [PATCH 15/61] Don't crash the process on a P2pRdy timeout or session error If a camera answered discovery but never completed the P2pRdy handshake (a flaky/half-wedged camera), the wait_for() in _run() raised asyncio.TimeoutError that was not caught (the surrounding try only handled CancelledError). The session task died with that exception, and the test server's amain() re-raised it via gather() and exited -- the whole web server crashed. Catch the P2pRdy timeout (treat it as a lost device, like the setup-device timeout already is) and add a catch-all so any unexpected session error tears that one session down instead of taking the process with it. Also fix stop() to clean up a session that started connecting but never reached CONNECTED (state still DISCONNECTED but with a live transport + queue tasks), which the previous idempotency guard skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index f41feaa..c39d642 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -318,7 +318,17 @@ async def _run(self): await self.send_initial_packets() try: - await asyncio.wait_for(self._p2p_rdy_debouncer.wait(), timeout=10) + try: + await asyncio.wait_for(self._p2p_rdy_debouncer.wait(), timeout=10) + except asyncio.TimeoutError: + # Camera answered discovery but never completed the P2pRdy + # handshake (common when it is flaky/half-wedged). Treat it as a + # lost device rather than letting an unhandled exception escape + # and take the whole process down. + logger.warning('%s did not become ready (no P2pRdy), disconnecting', self.dev.dev_id) + await self.send_close_pkt() + self._on_device_lost() + return logger.info('Connected to %s at %s, json=%s', self.dev.dev_id, self.dev.addr, self.dev.is_json) self.state = State.CONNECTED try: @@ -337,6 +347,16 @@ async def _run(self): logger.debug('Session main task cancelled, sending close packet') await self.send_close_pkt() raise + except Exception: + # A single session must never crash the whole process. Log it, tear + # the session down, and let discovery/HA reconnect. + logger.exception('Session for %s failed; disconnecting', self.dev.dev_id) + try: + await self.send_close_pkt() + except Exception: + pass + self._on_device_lost() + return async def loop_step(self): logger.debug(f"iterate in Session for {self.dev.dev_id}") @@ -371,9 +391,12 @@ def _on_device_lost(self): self.on_disconnect(self.dev) def stop(self): - if self.state == State.DISCONNECTED: - # Already stopped: stop() is reachable from _on_device_lost(), + if self.state == State.DISCONNECTED and self.transport is None: + # Already fully stopped. stop() is reachable from _on_device_lost(), # Device.close() and the CLI shutdown loop, so it must be idempotent. + # Note: a session that started connecting but never reached CONNECTED + # (e.g. P2pRdy timeout) is still DISCONNECTED but has a live transport + # and queue tasks, so we must fall through and clean those up. return logger.info('Stopping task for %s', self.dev.dev_id) self.device_is_ready.set() From 59daef74696808e8de31803fe0ed870ef7a9df56 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:25:50 +0300 Subject: [PATCH 16/61] Re-assert binary video resolution after stream start to lock it These cameras ignore the resolution set at stream-start and adaptively downgrade a few seconds in. Re-selecting the resolution mid-stream from the UI is known to make it stick, so mimic that: after CMD_PEER_LIVEVIDEO_START, schedule a delayed (5s) re-send of CMD_PEER_VIDEOPARAM_SET. Guarded so it no-ops if the stream was stopped during the wait. Experimental -- the lock behaviour and delay are based on observed camera behaviour and may need tuning per device. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/aiopppp/session.py b/aiopppp/session.py index c39d642..581a449 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -765,9 +765,27 @@ async def _request_video(self, mode): for video_param in video_params: await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, video_param, with_response=True) await self.send_command(BinaryCommands.CMD_PEER_LIVEVIDEO_START, b'', with_response=True) + # The camera adaptively drops the resolution a few seconds after the + # stream starts and ignores the resolution we set at start time. + # Re-asserting it mid-stream (which is what re-selecting it in the UI + # does) makes it stick, so schedule a delayed re-send. + asyncio.create_task(self._reassert_video_params(video_params)) else: await self.send_command(BinaryCommands.CMD_PEER_LIVEVIDEO_STOP, b'', with_response=True) + async def _reassert_video_params(self, video_params, delay=5): + """Re-send the resolution a few seconds in to lock it (camera ignores + the value set at stream start and self-downgrades otherwise).""" + await asyncio.sleep(delay) + if not self.is_video_requested or self.transport is None: + return + logger.info('%s: re-asserting video params to lock resolution', self.dev.dev_id) + try: + for video_param in video_params: + await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, video_param, with_response=True) + except Exception: + logger.debug('Re-assert video params failed', exc_info=True) + @staticmethod def _build_video_param(param_type, value): if isinstance(param_type, VideoParamType): From a7a390d003cf7e167181a7a37ebb5f7ca61713f7 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:41:05 +0300 Subject: [PATCH 17/61] Fix binary-protocol correctness bugs in the session/packet layer - Track the DRW index/epoch from Video-channel packets only. The camera counts the index independently per channel, so mixing command/audio indices into the wraparound check could spuriously flip video_epoch and shift chunk indices by 0x10000, corrupting frame reassembly (the likely root cause of "binary video not working"). - Lift the duplicated handle_drw/_get_drw_epoch into the base Session and dispatch command/audio channels through overridable hooks. - Bound drw_waiters and cancel wrap-collided/evicted futures so fire-and-forget commands (reboot, toggle_*, PTZ) can't leak ACK waiters. - Replace cmd_waiters on re-request via _reset_cmd_waiter so an unanswered response future can't orphan its awaiter; stop creating unused response waiters on the video start/param path. - Track the video-param re-assert task so it can't be GC'd mid-flight and is cancelled on stop(). - Fix _build_video_param for enum param types and enum/string values. - Rotate xq_bytes_encode/decode modulo the payload length so 1-3 byte payloads round-trip. - Never let a malformed datagram raise out of the UDP receive callbacks: guard parse_packet's type lookup and drop undecodable packets in both the session and discovery receive paths. Co-Authored-By: Claude Opus 4.8 --- aiopppp/discover.py | 9 +- aiopppp/packets.py | 20 ++++- aiopppp/session.py | 201 ++++++++++++++++++++++++-------------------- 3 files changed, 136 insertions(+), 94 deletions(-) diff --git a/aiopppp/discover.py b/aiopppp/discover.py index 303e300..e22e6a1 100644 --- a/aiopppp/discover.py +++ b/aiopppp/discover.py @@ -1,5 +1,6 @@ import asyncio import logging +import struct from .const import CAM_MAGIC, PacketType from .encrypt import ENC_METHODS @@ -64,7 +65,13 @@ def on_receive(self, data, addr, callback): except ValueError: return - pkt = parse_packet(decoded) + try: + pkt = parse_packet(decoded) + except (ValueError, struct.error, IndexError): + # A stray/corrupt datagram on the discovery socket must not abort + # on_receive; drop it and keep listening. + logger.debug('Dropping undecodable discovery datagram from %s', addr) + return logger.debug(f"Received {pkt} from {addr}") if pkt.type == PacketType.PunchPkt: diff --git a/aiopppp/packets.py b/aiopppp/packets.py index 7e026f5..12b35c7 100644 --- a/aiopppp/packets.py +++ b/aiopppp/packets.py @@ -73,11 +73,20 @@ def get_drw_payload(self): def xq_bytes_encode(data, shift): new_buf = bytes(b - 1 if b & 1 else b + 1 for b in data) + if not new_buf: + return b'' + # The rotation is modulo the buffer length; a raw shift larger than the + # payload (e.g. shift=4 on a 1-3 byte payload) would otherwise rotate by the + # wrong amount and fail to round-trip with xq_bytes_decode. + shift %= len(new_buf) return bytes(new_buf[shift:] + new_buf[:shift]) def xq_bytes_decode(data, shift): new_buf = bytes(b - 1 if b & 1 else b + 1 for b in data) + if not new_buf: + return b'' + shift %= len(new_buf) return bytes(new_buf[-shift:] + new_buf[:-shift]) def _inet_btoa(b: bytes) -> str: @@ -299,7 +308,14 @@ def parse_packet(data): 'Invalid pkt length: pkt.len=%d, real length=%d, [%s]', length, len(data) - 4, data.hex(' ')) - pkt_class, parse_func = PARSERS.get(PacketType(typ), (Packet, None)) + try: + packet_type = PacketType(typ) + except ValueError: + # A corrupt or unrecognized datagram must not raise out of the UDP + # receive callback; surface it as ValueError so callers drop it. + raise ValueError(f'Unknown packet type 0x{typ:02x}') + + pkt_class, parse_func = PARSERS.get(packet_type, (Packet, None)) if parse_func is None: - return pkt_class(PacketType(typ), data[4:]) + return pkt_class(packet_type, data[4:]) return parse_func(data[4:]) diff --git a/aiopppp/session.py b/aiopppp/session.py index 581a449..37023e5 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -191,8 +191,15 @@ def on_receive(self, data): # The transport is bound to the camera's address, so any datagram here # is proof of life for the dead-connection check in loop_step(). self.last_recv_at = datetime.datetime.now() - decoded = ENC_METHODS[self.dev.encryption][0](data) - pkt = parse_packet(decoded) + try: + decoded = ENC_METHODS[self.dev.encryption][0](data) + pkt = parse_packet(decoded) + except (ValueError, struct.error, KeyError, IndexError): + # One malformed datagram must never raise out of the asyncio + # datagram callback (which would spam "Exception in callback" and, + # in the worst case, wedge the transport). Log and drop it. + logger.debug('Dropping undecodable datagram (%d bytes): [%s]', len(data), data[:16].hex(' ')) + return # logger.debug(f"recv< {pkt} {pkt.get_payload()}") logger.debug(f"recv< {pkt.type}, len={len(pkt.get_payload())}") self.packet_queue.put_nowait(pkt) @@ -208,10 +215,27 @@ async def call_with_error_check(self, coro): async def send(self, pkt): await self.call_with_error_check(self._send(pkt)) + # Cap on outstanding DRW ACK waiters. A waiter is created for every DRW we + # send but only removed when its ACK arrives (handle_drw_ack) or its wait + # times out (_wait_ack). Fire-and-forget commands (reboot, toggle_*, PTZ) + # never wait, so their waiters would linger; bound the dict and evict the + # oldest so it can never grow without limit. + MAX_DRW_WAITERS = 256 + async def _send(self, pkt): logger.debug(f"send> {pkt}") if pkt.type == PacketType.Drw: + existing = self.drw_waiters.get(pkt._cmd_idx) + if existing is not None and not existing.done(): + # The 16-bit index wrapped back onto a still-pending waiter; that + # old send will never be matched now, so discard it. + existing.cancel() self.drw_waiters[pkt._cmd_idx] = asyncio.Future() + while len(self.drw_waiters) > self.MAX_DRW_WAITERS: + old_idx, old_fut = next(iter(self.drw_waiters.items())) + del self.drw_waiters[old_idx] + if not old_fut.done(): + old_fut.cancel() encoded_pkt = ENC_METHODS[self.dev.encryption][1](bytes(pkt)) self.transport.sendto(encoded_pkt, (self.dev.addr, self.dev.port)) @@ -272,18 +296,61 @@ async def _request_video(self, mode): async def handle_drw(self, drw_pkt): logger.debug('handle_drw(idx=%s, chn=%s)', drw_pkt._cmd_idx, drw_pkt._channel) await self.send(make_drw_ack_pkt(drw_pkt)) + self.last_drw_pkt_at = datetime.datetime.now() + + if drw_pkt._channel == Channel.Video: + # The camera counts the DRW index independently per channel, so only + # video-channel packets may drive epoch/wraparound tracking. Feeding + # command/audio indices (which advance on their own) in here would + # spuriously flip video_epoch and corrupt frame reassembly by an + # 0x10000 index shift. + pkt_epoch = self._get_drw_epoch(drw_pkt) + if pkt_epoch > self.video_epoch: + logger.info('Video epoch changed %s -> %s', self.video_epoch, pkt_epoch) + self.video_epoch = pkt_epoch + self.last_drw_pkt_idx = drw_pkt._cmd_idx + elif self.last_drw_pkt_idx < drw_pkt._cmd_idx: + self.last_drw_pkt_idx = drw_pkt._cmd_idx + + if self.video_stale_at: + logger.warning('Got video data while stale') + self.video_stale_at = None + self.video_chunk_queue.put_nowait((pkt_epoch, drw_pkt)) + elif drw_pkt._channel == Channel.Audio: + await self.handle_incoming_audio_packet(drw_pkt) + elif drw_pkt._channel == Channel.Command: + await self.handle_incoming_command_packet(drw_pkt) + + def _get_drw_epoch(self, drw_pkt): + if self.last_drw_pkt_idx > 0xff00 and drw_pkt._cmd_idx < 0x100: + return self.video_epoch + 1 + if self.video_epoch and self.last_drw_pkt_idx < 0x100 and drw_pkt._cmd_idx > 0xff00: + return self.video_epoch - 1 + return self.video_epoch + + async def handle_incoming_command_packet(self, drw_pkt): + pass + + async def handle_incoming_audio_packet(self, drw_pkt): + pass + + def _reset_cmd_waiter(self, cmd): + # Replace any pending response future for this command. Without this a + # second request whose first response never arrived would silently + # orphan the old future (and its awaiter would hang until timeout). + old = self.cmd_waiters.get(cmd.value) + if old is not None and not old.done(): + old.cancel() + fut = asyncio.Future() + self.cmd_waiters[cmd.value] = fut + return fut async def handle_drw_ack(self, pkt): cmd_idx_ack = int.from_bytes(pkt.get_payload()[4:6], 'big') logger.debug('handle_drw_ack(idx=%s)', cmd_idx_ack) - # logger.info('waiters: %s', self.drw_waiters) - if cmd_idx_ack in self.drw_waiters: - # logger.info( - # 'Got ACK for %d, proceed waiters, total waiters: %d', cmd_idx_ack, len(self.drw_waiters), - # ) - self.drw_waiters[cmd_idx_ack].set_result(pkt) - await asyncio.sleep(0) - del self.drw_waiters[cmd_idx_ack] + fut = self.drw_waiters.pop(cmd_idx_ack, None) + if fut is not None and not fut.done(): + fut.set_result(pkt) async def wait_ack(self, idx, timeout=5): return await self.call_with_error_check(self._wait_ack(idx, timeout)) @@ -400,6 +467,9 @@ def stop(self): return logger.info('Stopping task for %s', self.dev.dev_id) self.device_is_ready.set() + reassert_task = getattr(self, '_reassert_task', None) + if reassert_task and not reassert_task.done(): + reassert_task.cancel() if self.process_packet_task: self.process_packet_task.cancel() if self.process_video_task: @@ -454,7 +524,7 @@ async def send_command(self, cmd, *, with_response=False, **kwargs): self.outgoing_command_idx = (self.outgoing_command_idx + 1) & 0xFFFF pkt = JsonCmdPkt(pkt_idx, {**data, **kwargs, **self.get_common_data()}) if with_response: - self.cmd_waiters[cmd.value] = asyncio.Future() + self._reset_cmd_waiter(cmd) await self.send(pkt) return pkt_idx @@ -470,38 +540,6 @@ async def _request_video(self, mode): logger.info('Request video %s', mode) await self.send_command(JsonCommands.CMD_STREAM, video=mode) - def _get_drw_epoch(self, drw_pkt): - if self.last_drw_pkt_idx > 0xff00 and drw_pkt._cmd_idx < 0x100: - return self.video_epoch + 1 - if self.video_epoch and self.last_drw_pkt_idx < 0x100 and drw_pkt._cmd_idx > 0xff00: - return self.video_epoch - 1 - return self.video_epoch - - async def handle_drw(self, drw_pkt): - await super().handle_drw(drw_pkt) - self.last_drw_pkt_at = datetime.datetime.now() - - # # 0x10000 - max number of chunks in one epoch,we need to keep order of chunks - pkt_epoch = self._get_drw_epoch(drw_pkt) - - if pkt_epoch > self.video_epoch: - logger.info('Video epoch changed %s -> %s', self.video_epoch, pkt_epoch) - self.video_epoch = pkt_epoch - self.last_drw_pkt_idx = drw_pkt._cmd_idx - elif self.last_drw_pkt_idx < drw_pkt._cmd_idx: - self.last_drw_pkt_idx = drw_pkt._cmd_idx - - if drw_pkt._channel == Channel.Video: - # logger.debug(f'Got video data {drw_pkt.get_drw_payload()}') - if self.video_stale_at: - logger.warning('Got video data while stale') - self.video_stale_at = None - self.video_chunk_queue.put_nowait((pkt_epoch, drw_pkt)) - elif drw_pkt._channel == Channel.Audio: - pass - elif drw_pkt._channel == Channel.Command: - await self.handle_incoming_command_packet(drw_pkt) - async def handle_incoming_command_packet(self, drw_pkt): if isinstance(drw_pkt, JsonCmdPkt): response = drw_pkt.json_payload @@ -637,6 +675,7 @@ def __init__(self, *args, login='', password='', **kwargs): self.auth_login = login or self.DEFAULT_LOGIN self.auth_password = password or self.DEFAULT_PASSWORD self.ticket = b'\x00' * 4 + self._reassert_task = None async def send_initial_packets(self): pkt = make_punch_pkt(self.dev.dev_id) @@ -644,38 +683,6 @@ async def send_initial_packets(self): pkt.type = PacketType.P2pRdy await self.send(pkt) - async def handle_drw(self, drw_pkt): - await super().handle_drw(drw_pkt) - self.last_drw_pkt_at = datetime.datetime.now() - - # # 0x10000 - max number of chunks in one epoch,we need to keep order of chunks - pkt_epoch = self._get_drw_epoch(drw_pkt) - - if pkt_epoch > self.video_epoch: - logger.info('Video epoch changed %s -> %s', self.video_epoch, pkt_epoch) - self.video_epoch = pkt_epoch - self.last_drw_pkt_idx = drw_pkt._cmd_idx - elif self.last_drw_pkt_idx < drw_pkt._cmd_idx: - self.last_drw_pkt_idx = drw_pkt._cmd_idx - - if drw_pkt._channel == Channel.Video: - # logger.debug(f'Got video data {drw_pkt.get_drw_payload()}') - if self.video_stale_at: - logger.warning('Got video data while stale') - self.video_stale_at = None - self.video_chunk_queue.put_nowait((pkt_epoch, drw_pkt)) - elif drw_pkt._channel == Channel.Audio: - pass - elif drw_pkt._channel == Channel.Command: - await self.handle_incoming_command_packet(drw_pkt) - - def _get_drw_epoch(self, drw_pkt): - if self.last_drw_pkt_idx > 0xff00 and drw_pkt._cmd_idx < 0x100: - return self.video_epoch + 1 - if self.video_epoch and self.last_drw_pkt_idx < 0x100 and drw_pkt._cmd_idx > 0xff00: - return self.video_epoch - 1 - return self.video_epoch - async def handle_incoming_command_packet(self, drw_pkt): if isinstance(drw_pkt, BinaryCmdPkt): if drw_pkt.command == BinaryCommands.ACK_SYSTEM_USER_CHK and len(drw_pkt.cmd_payload) > 0: @@ -708,7 +715,7 @@ async def send_command(self, cmd, cmd_payload=b'', *, with_response=False, **kwa self.ticket, ) if with_response: - self.cmd_waiters[cmd.value] = asyncio.Future() + self._reset_cmd_waiter(cmd) await self.send(pkt) return pkt_idx @@ -763,44 +770,56 @@ async def _request_video(self, mode): if mode: for video_param in video_params: - await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, video_param, with_response=True) - await self.send_command(BinaryCommands.CMD_PEER_LIVEVIDEO_START, b'', with_response=True) + await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, video_param) + await self.send_command(BinaryCommands.CMD_PEER_LIVEVIDEO_START, b'') # The camera adaptively drops the resolution a few seconds after the # stream starts and ignores the resolution we set at start time. # Re-asserting it mid-stream (which is what re-selecting it in the UI - # does) makes it stick, so schedule a delayed re-send. - asyncio.create_task(self._reassert_video_params(video_params)) + # does) makes it stick, so schedule a delayed re-send. Keep a handle + # so it can't be garbage-collected mid-flight and is cancelled on stop. + if self._reassert_task and not self._reassert_task.done(): + self._reassert_task.cancel() + self._reassert_task = asyncio.create_task(self._reassert_video_params(video_params)) else: - await self.send_command(BinaryCommands.CMD_PEER_LIVEVIDEO_STOP, b'', with_response=True) + await self.send_command(BinaryCommands.CMD_PEER_LIVEVIDEO_STOP, b'') async def _reassert_video_params(self, video_params, delay=5): """Re-send the resolution a few seconds in to lock it (camera ignores the value set at stream start and self-downgrades otherwise).""" - await asyncio.sleep(delay) - if not self.is_video_requested or self.transport is None: - return - logger.info('%s: re-asserting video params to lock resolution', self.dev.dev_id) try: + await asyncio.sleep(delay) + if not self.is_video_requested or self.transport is None: + return + logger.info('%s: re-asserting video params to lock resolution', self.dev.dev_id) for video_param in video_params: - await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, video_param, with_response=True) + await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, video_param) + except asyncio.CancelledError: + raise except Exception: logger.debug('Re-assert video params failed', exc_info=True) @staticmethod def _build_video_param(param_type, value): if isinstance(param_type, VideoParamType): - param = param_type + param = param_type.value + name = param_type.name.replace('VIDEO_PARAM_TYPE_', '') else: - param = VideoParamType[f'VIDEO_PARAM_TYPE_{param_type.upper()}'].value + name = str(param_type).upper() + param = VideoParamType[f'VIDEO_PARAM_TYPE_{name}'].value - if isinstance(value, str): - value = globals()[f'Video{param_type.capitalize()}'][f'VIDEO_{param_type.upper()}_{value.upper()}'].value + if isinstance(value, Enum): + value = value.value + elif isinstance(value, str): + # Resolve a symbolic value (e.g. 'HD') against the matching + # Video enum, e.g. VideoResolution.VIDEO_RESOLUTION_HD. + enum_cls = globals()[f'Video{name.capitalize()}'] + value = enum_cls[f'VIDEO_{name}_{value.upper()}'].value return struct.pack(' Date: Sat, 4 Jul 2026 09:45:17 +0300 Subject: [PATCH 18/61] Split BinaryCommands into unambiguous enums BinaryCommands mixed config-section IDs (CFGID_*), ACK status codes (CMD_ACK_*) and the CGI vocabulary (CB_*) in with the real command opcodes, producing many duplicate values (e.g. CFGID_VERSION and CMD_ACK_OK both 0x0000; CB_IEGET_* aliasing CMD_NET_* at 0x6001-0x6005). Python silently turns the second name into an alias, so BinaryCommands(n) could never resolve to some names and parse_drw_pkt mislabelled aliased commands. Extract three separate enums -- DevCfgId, AckCode and CgiCommands -- so every enum now has strictly distinct values and value->name lookups are unambiguous. BinaryCommands keeps only real opcodes (196 distinct). No callers referenced the moved names (only const.py), so this is internally contained. Co-Authored-By: Claude Opus 4.8 --- aiopppp/const.py | 94 ++++++++++++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/aiopppp/const.py b/aiopppp/const.py index 9981abe..851c5c8 100644 --- a/aiopppp/const.py +++ b/aiopppp/const.py @@ -28,21 +28,21 @@ class PacketType(Enum): RlyHelloAck2 = 0x71 # if len >1?? -class BinaryCommands(Enum): +class DevCfgId(IntEnum): + """Config-section identifiers used by the DFTCFG import/export commands. + + These are section selectors carried inside a config command's payload, not + command opcodes, and they reuse the 0x0000-0x0019 range that the ACK status + codes also occupy. Keeping them in their own enum prevents value aliasing + that would make BinaryCommands(value) ambiguous. + """ CFGID_VERSION = 0x0000 - CMD_ACK_OK = 0x0000 CFGID_LANGUAGE = 0x0001 - CMD_ACK_UNAUTH = 0x0001 CFGID_PRODUCTE = 0x0002 - CMD_ACK_NO_PRIVILEGE = 0x0002 CFGID_UPGRADE = 0x0003 - CMD_ACK_INVALID_PARAM = 0x0003 CFGID_P2P = 0x0004 - CMD_ACK_CMDEXCUTE_FAILED = 0x0004 CFGID_TZ = 0x0005 - CMD_ACK_NONE_RESULT = 0x0005 CFGID_USER = 0x0006 - CMD_ACK_UNKNOWN = 0x0006 CFGID_OPR = 0x0007 CFGID_SERIAL = 0x0008 CFGID_WIRED = 0x0009 @@ -62,9 +62,25 @@ class BinaryCommands(Enum): CFGID_FTP = 0x0017 CFGID_PUSH = 0x0018 CFGID_WLANPMK = 0x0019 + + +class AckCode(IntEnum): + """Result/status codes returned in an ACK payload (not command opcodes).""" + CMD_ACK_OK = 0x0000 + CMD_ACK_UNAUTH = 0x0001 + CMD_ACK_NO_PRIVILEGE = 0x0002 + CMD_ACK_INVALID_PARAM = 0x0003 + CMD_ACK_CMDEXCUTE_FAILED = 0x0004 + CMD_ACK_NONE_RESULT = 0x0005 + CMD_ACK_UNKNOWN = 0x0006 + CMD_ACK_ILLIGAL = 0x03E8 + + +class BinaryCommands(Enum): + # Protocol-level markers: a DRW command frame is tagged BINCMD (255) or + # CGICMD (254) to select the binary vs the CGI command vocabulary. CGICMD = 0x00FE BINCMD = 0x00FF - CMD_ACK_ILLIGAL = 0x03E8 CMD_DEV_BROADCAST = 0x0EFF CMD_SYSTEM_DFTCFG_IMPORT = 0x1000 CMD_SYSTEM_DFTCFG_EXPORT = 0x1001 @@ -234,16 +250,46 @@ class BinaryCommands(Enum): CMD_PASSTHROUGH_STRING_PUT = 0x50FF ACK_PASSTHROUGH_STRING_PUT = 0x51FF CMD_SESSION_CHECK = 0x55FE - CB_IEGET_STATUS = 0x6001 CMD_NET_WIFISETTING_SET = 0x6001 - CB_IEGET_PARAM = 0x6002 CMD_NET_WIFISETTING_GET = 0x6002 - CB_IEGET_CAM_PARAMS = 0x6003 CMD_NET_WIFI_SCAN = 0x6003 - CB_IEGET_LOG = 0x6004 CMD_NET_WIREDSETTING_SET = 0x6004 - CB_IEGET_MISC = 0x6005 CMD_NET_WIREDSETTING_GET = 0x6005 + ACK_NET_WIFISETTING_SET = 0x6101 + ACK_NET_WIFISETTING_GET = 0x6102 + ACK_NET_WIFI_SCAN = 0x6103 + ACK_NET_WIREDSETTING_SET = 0x6104 + ACK_NET_WIREDSETTING_GET = 0x6105 + CMD_FRIEND_MSG = 0x7000 + CMD_LOCAL_SESSION_INF = 0xF000 + CMD_LOCAL_SESSION_CHECK = 0xF001 + CMD_LOCAL_SESSION_GET = 0xF002 + CMD_LOCAL_SESSION_CTRL = 0xF003 + CMD_LOCAL_REC_START = 0xF004 + CMD_LOCAL_REC_STOP = 0xF005 + CMD_LOCAL_REC_MERGECTRL = 0xF006 + CMD_LOCAL_P2P_START = 0xF007 + CMD_LOCAL_P2P_STOP = 0xF008 + CMD_SESSION_CLOSE = 0xF00F + CMD_LOCAL_PUSH_STRING = 0xF010 + CMD_LOCAL_PUSH_CFG = 0xF011 + CMD_LOCAL_RCVVID_DEC = 0xF012 + CMD_LOCAL_LAPSED = 0xF021 + + +class CgiCommands(IntEnum): + """The alternate CGI command vocabulary (selected by the CGICMD marker). + + Some A9/XD firmwares answer on these CB_* opcodes instead of the + BinaryCommands (BINCMD) set. They occupy an overlapping numeric range -- + e.g. 0x6001-0x6005 collide with CMD_NET_* -- so they must live in their own + enum to keep value-based lookups unambiguous. + """ + CB_IEGET_STATUS = 0x6001 + CB_IEGET_PARAM = 0x6002 + CB_IEGET_CAM_PARAMS = 0x6003 + CB_IEGET_LOG = 0x6004 + CB_IEGET_MISC = 0x6005 CB_IEGET_RECORD = 0x6006 CB_IEGET_RECORD_FILE = 0x6007 CB_IEGET_WIFI_SCAN = 0x6008 @@ -299,29 +345,9 @@ class BinaryCommands(Enum): CB_APP_VERSION = 0x6054 CB_CHECK_USER = 0x60A0 CB_IESET_BILL = 0x60A1 - ACK_NET_WIFISETTING_SET = 0x6101 - ACK_NET_WIFISETTING_GET = 0x6102 - ACK_NET_WIFI_SCAN = 0x6103 - ACK_NET_WIREDSETTING_SET = 0x6104 - ACK_NET_WIREDSETTING_GET = 0x6105 - CMD_FRIEND_MSG = 0x7000 CB_SET_P2PPARAM = 0x99F0 CB_GET_SYSOPR = 0x99FE CB_SET_SYSOPR = 0x99FF - CMD_LOCAL_SESSION_INF = 0xF000 - CMD_LOCAL_SESSION_CHECK = 0xF001 - CMD_LOCAL_SESSION_GET = 0xF002 - CMD_LOCAL_SESSION_CTRL = 0xF003 - CMD_LOCAL_REC_START = 0xF004 - CMD_LOCAL_REC_STOP = 0xF005 - CMD_LOCAL_REC_MERGECTRL = 0xF006 - CMD_LOCAL_P2P_START = 0xF007 - CMD_LOCAL_P2P_STOP = 0xF008 - CMD_SESSION_CLOSE = 0xF00F - CMD_LOCAL_PUSH_STRING = 0xF010 - CMD_LOCAL_PUSH_CFG = 0xF011 - CMD_LOCAL_RCVVID_DEC = 0xF012 - CMD_LOCAL_LAPSED = 0xF021 CB_SET_SINGLE_SETTING_DEFAULT = 0xFF01 CB_GET_FILE = 0xFF10 CB_PUT_FILE = 0xFF11 From 3d12585cb85c254d90df43fd6f7ee4af358a43b1 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:48:08 +0300 Subject: [PATCH 19/61] Make video reassembly incremental (drop per-chunk O(frame^2) work) process_video_frame ran on every incoming chunk and, for the pending frame, both rescanned the whole [index, last_index) range for completeness and scanned all received chunks/boundaries to prune. For a large keyframe spanning many chunks that is O(frame^2) CPU per frame. Track the assembling frame's window and its set of still-missing chunk indices. A chunk that lands in the current window just discards its index from the missing set (O(1)); the O(frame) missing-set recompute and the prune now run only when the window advances (once per frame). Frame output is byte-identical. Also hoist the frame marker to a module constant and reset the new tracking state in stop_video(). Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 70 +++++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 37023e5..3f5c07a 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -36,6 +36,9 @@ logger = logging.getLogger(__name__) +# Prefix of the 0x20-byte header that marks the first chunk of a video frame. +VIDEO_MARKER = b'\x55\xaa\x15\xa8' + class State(Enum): DISCONNECTED = 0 @@ -85,6 +88,12 @@ def __init__(self, *args, **kwargs): self.video_received = {} self.video_boundaries = set() self.last_video_frame = -1 + # The frame currently being assembled is delimited by the top two + # boundaries. We track that window and the set of still-missing chunk + # indices in it incrementally, so completeness is an O(1) set update per + # chunk instead of an O(frame) rescan (which was O(frame^2) per frame). + self._frame_window = (None, None) + self._frame_missing = set() async def process_video_queue(self): while True: @@ -97,49 +106,52 @@ def start_video_queue(self): async def handle_incoming_video_packet(self, pkt_epoch, pkt): video_payload = pkt.get_drw_payload() # logger.info(f'- video frame {pkt._cmd_idx}') - video_marker = b'\x55\xaa\x15\xa8' # next \x03 - video marker video_chunk_idx = pkt._cmd_idx + 0x10000 * pkt_epoch # 0x20 - size of the header starting with this magic - if video_payload.startswith(video_marker): + if video_payload.startswith(VIDEO_MARKER): self.video_boundaries.add(video_chunk_idx) self.video_received[video_chunk_idx] = video_payload[0x20:] else: self.video_received[video_chunk_idx] = video_payload - await self.process_video_frame() + await self.process_video_frame(video_chunk_idx) - async def process_video_frame(self): + async def process_video_frame(self, new_idx=None): if len(self.video_boundaries) <= 1: return + # After pruning, video_boundaries only holds the current pending pair + # (plus any freshly-arrived higher boundary), so this sort is over a + # handful of items. frame_starts = sorted(self.video_boundaries) index = frame_starts[-2] last_index = frame_starts[-1] - if index != self.last_video_frame: - # Cheap completeness check: stop at the first gap instead of building - # the payload (and the diagnostic string) on every incoming chunk. - complete = all(i in self.video_received for i in range(index, last_index)) - if logger.isEnabledFor(logging.DEBUG): - completeness = ''.join( - 'x' if i in self.video_received else '_' - for i in range(index, last_index) - ) - logger.debug(f".. completeness: {completeness}") - - if complete: - self.last_video_frame = index - data = b''.join(self.video_received[i] for i in range(index, last_index)) - await self.frame_buffer.publish(VideoFrame(idx=index, data=data)) - - # Only the last two boundaries are ever assembled, so chunks/boundaries - # below the current frame start can never be published. Drop them on - # every call (not just on completion) so a permanently incomplete frame - # from packet loss can't make these buffers grow without bound. - for idx in [i for i in self.video_received if i < index]: - del self.video_received[idx] - for idx in [i for i in self.video_boundaries if i < index]: - self.video_boundaries.remove(idx) + if (index, last_index) != self._frame_window: + # The frame window advanced. Recompute the missing set and drop + # everything below the new frame start. Both are O(frame) but run + # once per frame here, not once per incoming chunk. + self._frame_window = (index, last_index) + self._frame_missing = {i for i in range(index, last_index) if i not in self.video_received} + for idx in [i for i in self.video_received if i < index]: + del self.video_received[idx] + for idx in [i for i in self.video_boundaries if i < index]: + self.video_boundaries.discard(idx) + elif new_idx is not None: + # Same window: the chunk we just stored may have filled a gap. + self._frame_missing.discard(new_idx) + + if index != self.last_video_frame and not self._frame_missing: + self.last_video_frame = index + data = b''.join(self.video_received[i] for i in range(index, last_index)) + await self.frame_buffer.publish(VideoFrame(idx=index, data=data)) + + if logger.isEnabledFor(logging.DEBUG): + completeness = ''.join( + 'x' if i in self.video_received else '_' + for i in range(index, last_index) + ) + logger.debug('.. completeness: %s', completeness) class Session(PacketQueueMixin, VideoQueueMixin): @@ -283,6 +295,8 @@ async def stop_video(self): self.video_boundaries = set() self.video_epoch = 0 self.last_video_frame = -1 + self._frame_window = (None, None) + self._frame_missing = set() while not self.video_chunk_queue.empty(): self.video_chunk_queue.get_nowait() await self._request_video(0) From 576f6208550704d60d1a1e799668e67c2d074f8f Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:49:18 +0300 Subject: [PATCH 20/61] Detect video-only stalls on binary (and all) sessions Binary sessions only had the base receive-timeout liveness check, which a camera that keeps ACKing P2PAlive while sending no video passes forever -- the stream silently zombies. Move the JSON video-stall logic (re-request after 5s of no DRW frames, disconnect after a further 10s) into the base Session.loop_step so both protocols share it, and drop the now-redundant JsonSession/BinarySession loop_step overrides. Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 3f5c07a..6cab4ab 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -439,9 +439,32 @@ async def _run(self): self._on_device_lost() return + # Seconds of no video (while streaming) before we re-request it, and the + # further grace period before giving up on the connection. + VIDEO_REREQUEST_SEC = 5 + VIDEO_DEAD_SEC = 10 + async def loop_step(self): logger.debug(f"iterate in Session for {self.dev.dev_id}") now = datetime.datetime.now() + + # Video liveness. Applies to both protocols: a binary camera that keeps + # answering P2PAlive but sends no video would otherwise pass the base + # receive-timeout check forever and zombie. Re-request after a short + # gap, then disconnect if that doesn't revive the stream. + if ( + self.is_video_requested and not self.video_stale_at and + (now - self.last_drw_pkt_at).total_seconds() > self.VIDEO_REREQUEST_SEC + ): + self.video_stale_at = self.last_drw_pkt_at + logger.info('No video for %ds. Re-requesting video', self.VIDEO_REREQUEST_SEC) + await self._request_video(1) + if self.video_stale_at and (now - self.video_stale_at).total_seconds() > self.VIDEO_DEAD_SEC: + logger.warning('No video for %ds. Disconnecting', self.VIDEO_DEAD_SEC) + await self.send_close_pkt() + self._on_device_lost() + return + if (now - self.last_recv_at).total_seconds() > self.RECV_TIMEOUT_SEC: logger.warning( 'No packets from %s for %ds: connection is dead, disconnecting', @@ -604,24 +627,6 @@ async def setup_device(self): logger.info('Camera properties: %s', cam_properties) self.device_is_ready.set() - async def loop_step(self): - if ( - self.is_video_requested and not self.video_stale_at and - (datetime.datetime.now() - self.last_drw_pkt_at).total_seconds() > 5 - ): - self.video_stale_at = self.last_drw_pkt_at - logger.info('No video for 5 seconds. Re-request video ') - await self._request_video(1) - if self.video_stale_at and (datetime.datetime.now() - self.video_stale_at).total_seconds() > 10: - # camera disconnected - logger.warning('No video for 10 seconds. Disconnecting') - await self.send_close_pkt() - self._on_device_lost() - # Session is being torn down; don't fall through to the base - # loop_step (which would touch the now-closed transport). - return - await super().loop_step() - async def control(self, no_ack=False, **kwargs): idx = await self.send_command(JsonCommands.CMD_DEV_CONTROL, **kwargs) if not no_ack: @@ -862,9 +867,6 @@ async def setup_device(self): logger.info('Camera properties: %s', self.dev_properties) self.device_is_ready.set() - async def loop_step(self): - await super().loop_step() - async def reboot(self, **kwargs): await self.send_command(BinaryCommands.CMD_SYSTEM_REBOOT) From f8800edd4ddee73fe2e4c852c435b4b69d245373 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:50:28 +0300 Subject: [PATCH 21/61] Binary: explicit IR/light control, snapshot, factory reset, lamp The binary session blind-toggled IR and white light (ignoring the requested value) and stubbed reset/lamp. Send an explicit on/off state in the *_ONOFF payload, implement reset via the default-config recovery command, alias lamp to the fill light, and add get_snapshot via CMD_SNAPSHOT_GET. Extend ACKS/REV_ACKS so IRCUT/LIGHTFILL/REBOOT/SNAPSHOT results correlate instead of being uncorrelated fire-and-forget. Payload/response framing for these commands is derived from the decompiled app dispatch and unverified against hardware. Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 6cab4ab..25fcccf 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -686,6 +686,10 @@ class BinarySession(Session): BinaryCommands.CMD_PEER_LIVEVIDEO_START: BinaryCommands.ACK_PEER_LIVEVIDEO_START, BinaryCommands.CMD_PEER_LIVEVIDEO_STOP: BinaryCommands.ACK_PEER_LIVEVIDEO_STOP, BinaryCommands.CMD_SYSTEM_STATUS_GET: BinaryCommands.ACK_SYSTEM_STATUS_GET, + BinaryCommands.CMD_PEER_IRCUT_ONOFF: BinaryCommands.ACK_PEER_IRCUT_ONOFF, + BinaryCommands.CMD_PEER_LIGHTFILL_ONOFF: BinaryCommands.ACK_PEER_LIGHTFILL_ONOFF, + BinaryCommands.CMD_SYSTEM_REBOOT: BinaryCommands.ACK_SYSTEM_REBOOT, + BinaryCommands.CMD_SNAPSHOT_GET: BinaryCommands.ACK_SNAPSHOT_GET, } REV_ACKS = {v: k for k, v in ACKS.items()} @@ -867,20 +871,40 @@ async def setup_device(self): logger.info('Camera properties: %s', self.dev_properties) self.device_is_ready.set() + @staticmethod + def _onoff_payload(value): + # The *_ONOFF commands carry the desired state as a little-endian int + # (an IntegerBean in the vendor SDK), so we can set an explicit on/off + # state instead of blind-toggling. + return struct.pack(' Date: Sat, 4 Jul 2026 09:51:49 +0300 Subject: [PATCH 22/61] Binary: add PTZ preset goto/save Add ptz_goto_preset/ptz_set_preset on top of the existing direction-based PTZ, reusing the passthrough PTZ frame with the preset index in the third field and PRE_TO/PRE_REC as the direction. Preset encoding derived from the decompiled app PTZ constants and unverified against hardware. Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 25fcccf..37da2bb 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -920,9 +920,24 @@ async def step_rotate(self, value, **kwargs): await asyncio.sleep(0.2) await self.rotate_stop() + async def ptz_goto_preset(self, index, **kwargs): + """Move to a stored PTZ preset position.""" + logger.info('%s: goto PTZ preset %s', self.dev.dev_id, index) + data = self._pack_ptz_dir_cmd(PtzDirection.PTZ_DIRECTION_PRE_TO, index) + await self.send_command(BinaryCommands.CMD_PASSTHROUGH_STRING_PUT, data) + + async def ptz_set_preset(self, index, **kwargs): + """Store the current position as a PTZ preset.""" + logger.info('%s: save PTZ preset %s', self.dev.dev_id, index) + data = self._pack_ptz_dir_cmd(PtzDirection.PTZ_DIRECTION_PRE_REC, index) + await self.send_command(BinaryCommands.CMD_PASSTHROUGH_STRING_PUT, data) + @staticmethod - def _pack_ptz_dir_cmd(ptz: PtzDirection) -> bytes: - data = struct.pack('>III', PtzParamType.PTZ_PARAM_TYPE_DIRECTION, ptz, 0) + def _pack_ptz_dir_cmd(ptz: PtzDirection, index: int = 0) -> bytes: + # The passthrough PTZ frame is (param_type, direction, arg). For plain + # moves arg is 0; for presets the direction is PRE_TO/PRE_REC and arg is + # the preset index. + data = struct.pack('>III', PtzParamType.PTZ_PARAM_TYPE_DIRECTION, int(ptz), index) return pack_passtrough_cmd(BinaryCommands.CMD_PTZ_SET.value, data) From bf3cd1123e968aede5a8ad6e20c0bcf704402711 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:52:46 +0300 Subject: [PATCH 23/61] Binary: expose full video-parameter surface Add get_video_param (VIDEOPARAM_GET, ACK-correlated) and convenience setters for resolution/bitrate/framerate/brightness/contrast/saturation/ sharpness/rotate/scene, all delegating to the existing set_video_param / _build_video_param path. Scene modes are selected via their own VideoParamType. Parameter framing follows the decompiled VCtrlParam table and is unverified against hardware. Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/aiopppp/session.py b/aiopppp/session.py index 37da2bb..5143fdc 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -690,6 +690,7 @@ class BinarySession(Session): BinaryCommands.CMD_PEER_LIGHTFILL_ONOFF: BinaryCommands.ACK_PEER_LIGHTFILL_ONOFF, BinaryCommands.CMD_SYSTEM_REBOOT: BinaryCommands.ACK_SYSTEM_REBOOT, BinaryCommands.CMD_SNAPSHOT_GET: BinaryCommands.ACK_SNAPSHOT_GET, + BinaryCommands.CMD_PEER_VIDEOPARAM_GET: BinaryCommands.ACK_PEER_VIDEOPARAM_GET, } REV_ACKS = {v: k for k, v in ACKS.items()} @@ -844,6 +845,58 @@ async def set_video_param(self, name, value): payload = self._build_video_param(name, value) await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, payload) + async def get_video_param(self, param, timeout=5): + """Read back a single video parameter. `param` may be a VideoParamType + or a symbolic name (e.g. 'resolution'). Returns the raw ACK payload.""" + if isinstance(param, VideoParamType): + param_val = param.value + else: + param_val = VideoParamType[f'VIDEO_PARAM_TYPE_{str(param).upper()}'].value + idx = await self.send_command( + BinaryCommands.CMD_PEER_VIDEOPARAM_GET, struct.pack('32s128s', self.auth_login.encode('utf-8'), self.auth_password.encode('utf-8')) From c3e2f8b743d50635674f53a246f0e7e9f905762e Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:54:14 +0300 Subject: [PATCH 24/61] Binary: add system and network commands Add device-info (INF_GET), alias set, datetime get/set, user get, and Wi-Fi/wired network get/set/scan wrappers, each ACK-correlated via a small _request helper. Set payloads (alias, datetime, wifi) use best-effort wire layouts derived from the decompiled command table and are unverified against hardware. Also stop parse_dev_status from reporting system uptime as the Wi-Fi dBm value; the real signal field isn't identified in this struct, so 'dbm' is now None instead of a bogus reading. Co-Authored-By: Claude Opus 4.8 --- aiopppp/packets.py | 4 +++- aiopppp/session.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/aiopppp/packets.py b/aiopppp/packets.py index 12b35c7..f600d82 100644 --- a/aiopppp/packets.py +++ b/aiopppp/packets.py @@ -152,7 +152,9 @@ def parse_dev_status(data): return { 'tz': f"UTC{time_zone // 3600:+d}", #time zone is in seconds 'uptime': sys_uptime, - 'dbm': sys_uptime, #not sure if that is wifi dbm or system uptime + # Real Wi-Fi signal strength is not identified in this 124-byte struct; + # don't masquerade the uptime as dBm (it produced bogus signal readings). + 'dbm': None, 'devName': dev_name.decode('ascii', errors='ignore').rstrip('\0'), 'sdStatus': sd_status, 'p2pStatus': p2p_status, diff --git a/aiopppp/session.py b/aiopppp/session.py index 5143fdc..5736e32 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -691,6 +691,15 @@ class BinarySession(Session): BinaryCommands.CMD_SYSTEM_REBOOT: BinaryCommands.ACK_SYSTEM_REBOOT, BinaryCommands.CMD_SNAPSHOT_GET: BinaryCommands.ACK_SNAPSHOT_GET, BinaryCommands.CMD_PEER_VIDEOPARAM_GET: BinaryCommands.ACK_PEER_VIDEOPARAM_GET, + BinaryCommands.CMD_SYSTEM_INF_GET: BinaryCommands.ACK_SYSTEM_INF_GET, + BinaryCommands.CMD_SYSTEM_ALIAS_SET: BinaryCommands.ACK_SYSTEM_ALIAS_SET, + BinaryCommands.CMD_SYSTEM_DATETIME_GET: BinaryCommands.ACK_SYSTEM_DATETIME_GET, + BinaryCommands.CMD_SYSTEM_DATETIME_SET: BinaryCommands.ACK_SYSTEM_DATETIME_SET, + BinaryCommands.CMD_SYSTEM_USER_GET: BinaryCommands.ACK_SYSTEM_USER_GET, + BinaryCommands.CMD_NET_WIFISETTING_GET: BinaryCommands.ACK_NET_WIFISETTING_GET, + BinaryCommands.CMD_NET_WIFISETTING_SET: BinaryCommands.ACK_NET_WIFISETTING_SET, + BinaryCommands.CMD_NET_WIFI_SCAN: BinaryCommands.ACK_NET_WIFI_SCAN, + BinaryCommands.CMD_NET_WIREDSETTING_GET: BinaryCommands.ACK_NET_WIREDSETTING_GET, } REV_ACKS = {v: k for k, v in ACKS.items()} @@ -917,6 +926,55 @@ async def get_status(self): status_result = await self.wait_cmd_result(BinaryCommands.CMD_SYSTEM_STATUS_GET) return {**parse_dev_status(status_result), 'raw': status_result.hex(' ')} + async def _request(self, cmd, payload=b'', timeout=5): + """Send a command that expects a response and return its raw ACK + payload (b'' on timeout/no-answer).""" + idx = await self.send_command(cmd, payload, with_response=True) + await self.wait_ack(idx) + return await self.wait_cmd_result(cmd, timeout=timeout) + + async def get_device_info(self, timeout=5): + """Fetch the extended device-info block (CMD_SYSTEM_INF_GET). Returned + as raw bytes -- the struct layout is firmware-specific and unverified.""" + return await self._request(BinaryCommands.CMD_SYSTEM_INF_GET, timeout=timeout) + + async def set_alias(self, name): + """Set the camera's display name/alias.""" + payload = struct.pack('<64s', name.encode('utf-8')[:64]) + await self.send_command(BinaryCommands.CMD_SYSTEM_ALIAS_SET, payload) + + async def get_datetime(self, timeout=5): + return await self._request(BinaryCommands.CMD_SYSTEM_DATETIME_GET, timeout=timeout) + + async def set_datetime(self, when=None, tz_seconds=0): + """Set the device clock. Sends a unix timestamp plus timezone offset in + seconds. The exact wire layout is unverified against hardware.""" + if when is None: + when = datetime.datetime.now() + ts = int(when.timestamp()) + payload = struct.pack(' Date: Sat, 4 Jul 2026 09:55:29 +0300 Subject: [PATCH 25/61] Binary: add SD card listing and playback control Add get_sd_info, list_recordings/list_pictures (optional date filter), is_recording, and peer playback control (start/stop/pause/resume/seek/ speed/step). Playback video is delivered on the normal video channel, so it flows through the existing frame pipeline; playback_start/stop drive is_video_requested and the video-state callback accordingly. Payloads (date filters, filenames, offsets) follow the decompiled command table and the a9-v720 reference and are unverified against hardware. Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/aiopppp/session.py b/aiopppp/session.py index 5736e32..298884c 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -700,6 +700,16 @@ class BinarySession(Session): BinaryCommands.CMD_NET_WIFISETTING_SET: BinaryCommands.ACK_NET_WIFISETTING_SET, BinaryCommands.CMD_NET_WIFI_SCAN: BinaryCommands.ACK_NET_WIFI_SCAN, BinaryCommands.CMD_NET_WIREDSETTING_GET: BinaryCommands.ACK_NET_WIREDSETTING_GET, + BinaryCommands.CMD_SD_INFO_GET: BinaryCommands.ACK_SD_INFO_GET, + BinaryCommands.CMD_SD_RECORDFILE_GET: BinaryCommands.ACK_SD_RECORDFILE_GET, + BinaryCommands.CMD_SD_PICFILE_GET: BinaryCommands.ACK_SD_PICFILE_GET, + BinaryCommands.CMD_SD_RECORDING_NOW: BinaryCommands.ACK_SD_RECORDING_NOW, + BinaryCommands.CMD_PEER_PLAYBACK_START: BinaryCommands.ACK_PEER_PLAYBACK_START, + BinaryCommands.CMD_PEER_PLAYBACK_STOP: BinaryCommands.ACK_PEER_PLAYBACK_STOP, + BinaryCommands.CMD_PEER_PLAYBACK_SEEK: BinaryCommands.ACK_PEER_PLAYBACK_SEEK, + BinaryCommands.CMD_PEER_PLAYBACK_SPEED: BinaryCommands.ACK_PEER_PLAYBACK_SPEED, + BinaryCommands.CMD_PEER_PLAYBACK_PAUSE: BinaryCommands.ACK_PEER_PLAYBACK_PAUSE, + BinaryCommands.CMD_PEER_PLAYBACK_RESUME: BinaryCommands.ACK_PEER_PLAYBACK_RESUME, } REV_ACKS = {v: k for k, v in ACKS.items()} @@ -975,6 +985,70 @@ async def scan_wifi(self, timeout=10): async def get_wired_settings(self, timeout=5): return await self._request(BinaryCommands.CMD_NET_WIREDSETTING_GET, timeout=timeout) + # --- SD card & playback ------------------------------------------------ + # Reference: intx82/a9-v720. Payloads (date filters, filenames, offsets) + # follow the decompiled command table / PlaybackCtrlBean and are all + # unverified against hardware; GET calls return raw ACK bytes for the + # caller to parse per its device. + + async def get_sd_info(self, timeout=5): + """SD card capacity/status block (CMD_SD_INFO_GET).""" + return await self._request(BinaryCommands.CMD_SD_INFO_GET, timeout=timeout) + + async def list_recordings(self, day=None, timeout=10): + """List recorded video files, optionally filtered to a given date. + `day` may be a datetime/date; sent as an 8-byte YYYYMMDD ascii filter.""" + payload = b'' + if day is not None: + payload = day.strftime('%Y%m%d').encode('ascii') + return await self._request(BinaryCommands.CMD_SD_RECORDFILE_GET, payload, timeout=timeout) + + async def list_pictures(self, day=None, timeout=10): + payload = b'' + if day is not None: + payload = day.strftime('%Y%m%d').encode('ascii') + return await self._request(BinaryCommands.CMD_SD_PICFILE_GET, payload, timeout=timeout) + + async def is_recording(self, timeout=5): + return await self._request(BinaryCommands.CMD_SD_RECORDING_NOW, timeout=timeout) + + @staticmethod + def _playback_payload(filename='', offset=0): + name = filename.encode('utf-8') if isinstance(filename, str) else (filename or b'') + return struct.pack(' Date: Sat, 4 Jul 2026 10:00:19 +0300 Subject: [PATCH 26/61] Binary: two-way audio (G.711 listen + talk-back) Add a dependency-free G.711 A-law/u-law codec module (aiopppp/audio.py; the stdlib audioop it would replace was removed in Python 3.13). Decode received audio DRW chunks to 16-bit PCM and publish them on an audio buffer (start_audio/stop_audio/get_audio_frame), and encode PCM for talk-back over the audio DRW channel (start_talk/stop_talk/send_audio). Add an AudioFrame type and a make_audio_drw_pkt helper, plus ACK mappings for the live-audio commands. Codecs are verified by the G.711 decode/encode idempotence property; the audio DRW framing on the wire is derived from the decompiled app and unverified against hardware. Co-Authored-By: Claude Opus 4.8 --- aiopppp/audio.py | 141 +++++++++++++++++++++++++++++++++++++++++++++ aiopppp/packets.py | 5 ++ aiopppp/session.py | 67 ++++++++++++++++++++- aiopppp/types.py | 7 +++ 4 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 aiopppp/audio.py diff --git a/aiopppp/audio.py b/aiopppp/audio.py new file mode 100644 index 0000000..8530560 --- /dev/null +++ b/aiopppp/audio.py @@ -0,0 +1,141 @@ +"""G.711 (A-law / u-law) codecs. + +The cheap PPPP cameras carry audio as 8 kHz 8-bit G.711 (A-law on the iLnk +firmware, u-law on some others). We decode to signed 16-bit little-endian PCM +for playback and encode PCM back for talk-back. + +Pure Python and dependency-free on purpose: the stdlib ``audioop`` module that +would normally do this was removed in Python 3.13, and aiopppp targets 3.7+. +Ported from the reference Sun ``g711.c``; decode tables are precomputed at +import, encode uses the standard segment search. +""" + +_SIGN_BIT = 0x80 +_QUANT_MASK = 0x0F +_SEG_SHIFT = 4 +_SEG_MASK = 0x70 + +_SEG_AEND = (0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF) +_SEG_UEND = (0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF) +_BIAS = 0x84 +_CLIP = 8159 + + +def _search(val, table): + for i, end in enumerate(table): + if val <= end: + return i + return len(table) + + +def _alaw2linear(a_val): + a_val ^= 0x55 + t = (a_val & _QUANT_MASK) << 4 + seg = (a_val & _SEG_MASK) >> _SEG_SHIFT + if seg == 0: + t += 8 + elif seg == 1: + t += 0x108 + else: + t += 0x108 + t <<= seg - 1 + return t if (a_val & _SIGN_BIT) else -t + + +def _linear2alaw(pcm_val): + pcm_val >>= 3 + if pcm_val >= 0: + mask = 0xD5 + else: + mask = 0x55 + pcm_val = -pcm_val - 1 + seg = _search(pcm_val, _SEG_AEND) + if seg >= 8: + return 0x7F ^ mask + aval = seg << _SEG_SHIFT + if seg < 2: + aval |= (pcm_val >> 1) & _QUANT_MASK + else: + aval |= (pcm_val >> seg) & _QUANT_MASK + return aval ^ mask + + +def _ulaw2linear(u_val): + u_val = ~u_val & 0xFF + t = ((u_val & _QUANT_MASK) << 3) + _BIAS + t <<= (u_val & _SEG_MASK) >> _SEG_SHIFT + return (_BIAS - t) if (u_val & _SIGN_BIT) else (t - _BIAS) + + +def _linear2ulaw(pcm_val): + pcm_val >>= 2 + if pcm_val < 0: + pcm_val = -pcm_val + mask = 0x7F + else: + mask = 0xFF + if pcm_val > _CLIP: + pcm_val = _CLIP + pcm_val += _BIAS >> 2 + seg = _search(pcm_val, _SEG_UEND) + if seg >= 8: + return 0x7F ^ mask + uval = (seg << 4) | ((pcm_val >> (seg + 1)) & 0xF) + return (uval ^ mask) & 0xFF + + +# Precomputed decode tables: G.711 code -> signed 16-bit sample. +_ALAW_DECODE = [_alaw2linear(a) for a in range(256)] +_ULAW_DECODE = [_ulaw2linear(u) for u in range(256)] + + +def _clamp16(v): + if v > 32767: + return 32767 + if v < -32768: + return -32768 + return v + + +def alaw_decode(data: bytes) -> bytes: + """A-law bytes -> signed 16-bit little-endian PCM.""" + out = bytearray(len(data) * 2) + for i, b in enumerate(data): + s = _clamp16(_ALAW_DECODE[b]) & 0xFFFF + out[2 * i] = s & 0xFF + out[2 * i + 1] = (s >> 8) & 0xFF + return bytes(out) + + +def ulaw_decode(data: bytes) -> bytes: + """u-law bytes -> signed 16-bit little-endian PCM.""" + out = bytearray(len(data) * 2) + for i, b in enumerate(data): + s = _clamp16(_ULAW_DECODE[b]) & 0xFFFF + out[2 * i] = s & 0xFF + out[2 * i + 1] = (s >> 8) & 0xFF + return bytes(out) + + +def alaw_encode(pcm: bytes) -> bytes: + """Signed 16-bit little-endian PCM -> A-law bytes.""" + out = bytearray(len(pcm) // 2) + for i in range(len(out)): + sample = int.from_bytes(pcm[2 * i:2 * i + 2], 'little', signed=True) + out[i] = _linear2alaw(sample) + return bytes(out) + + +def ulaw_encode(pcm: bytes) -> bytes: + """Signed 16-bit little-endian PCM -> u-law bytes.""" + out = bytearray(len(pcm) // 2) + for i in range(len(out)): + sample = int.from_bytes(pcm[2 * i:2 * i + 2], 'little', signed=True) + out[i] = _linear2ulaw(sample) + return bytes(out) + + +CODECS = { + 'alaw': (alaw_decode, alaw_encode), + 'ulaw': (ulaw_decode, ulaw_encode), +} diff --git a/aiopppp/packets.py b/aiopppp/packets.py index f600d82..da7e440 100644 --- a/aiopppp/packets.py +++ b/aiopppp/packets.py @@ -269,6 +269,11 @@ def parse_drw_pkt(data): return DrwPkt(channel, cmd_idx, data[4:]) +def make_audio_drw_pkt(cmd_idx, payload): + """Outgoing audio (talk-back) frame on the audio DRW channel.""" + return DrwPkt(Channel.Audio, cmd_idx, payload) + + def make_drw_ack_pkt(drw_pkt): return Packet( PacketType.DrwAck, diff --git a/aiopppp/session.py b/aiopppp/session.py index 298884c..a265cf8 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -17,11 +17,13 @@ VideoResolution, VideoRotate, ) +from .audio import CODECS from .encrypt import ENC_METHODS from .exceptions import AuthError, CommandResultError from .packets import ( BinaryCmdPkt, JsonCmdPkt, + make_audio_drw_pkt, make_close_pkt, make_drw_ack_pkt, make_p2palive_ack_pkt, @@ -31,7 +33,7 @@ parse_dev_status, parse_packet, ) -from .types import Channel, DeviceDescriptor, VideoFrame +from .types import AudioFrame, Channel, DeviceDescriptor, VideoFrame from .utils import DebounceEvent logger = logging.getLogger(__name__) @@ -710,15 +712,23 @@ class BinarySession(Session): BinaryCommands.CMD_PEER_PLAYBACK_SPEED: BinaryCommands.ACK_PEER_PLAYBACK_SPEED, BinaryCommands.CMD_PEER_PLAYBACK_PAUSE: BinaryCommands.ACK_PEER_PLAYBACK_PAUSE, BinaryCommands.CMD_PEER_PLAYBACK_RESUME: BinaryCommands.ACK_PEER_PLAYBACK_RESUME, + BinaryCommands.CMD_PEER_LIVEAUDIO_START: BinaryCommands.ACK_PEER_LIVEAUDIO_START, + BinaryCommands.CMD_PEER_LIVEAUDIO_STOP: BinaryCommands.ACK_PEER_LIVEAUDIO_STOP, + BinaryCommands.CMD_PEER_AUDIOPARAM_GET: BinaryCommands.ACK_PEER_AUDIOPARAM_GET, } REV_ACKS = {v: k for k, v in ACKS.items()} - def __init__(self, *args, login='', password='', **kwargs): + def __init__(self, *args, login='', password='', audio_codec='alaw', **kwargs): super().__init__(*args, **kwargs) self.auth_login = login or self.DEFAULT_LOGIN self.auth_password = password or self.DEFAULT_PASSWORD self.ticket = b'\x00' * 4 self._reassert_task = None + # Received-audio pipeline (G.711 -> PCM), talk-back state. + self.audio_buffer = SharedFrameBuffer() + self.audio_codec = audio_codec if audio_codec in CODECS else 'alaw' + self.is_audio_requested = False + self._outgoing_audio_idx = 0 async def send_initial_packets(self): pkt = make_punch_pkt(self.dev.dev_id) @@ -1049,6 +1059,59 @@ async def playback_speed(self, speed): async def playback_step(self): await self.send_command(BinaryCommands.CMD_PEER_PLAYBACK_STEP) + # --- Audio (listen + talk-back) --------------------------------------- + # The cameras carry 8 kHz G.711 audio. Received audio is decoded to signed + # 16-bit PCM and published on audio_buffer; talk-back encodes PCM and sends + # it on the audio DRW channel. Wire framing is derived from the decompiled + # app and unverified against hardware. + + async def handle_incoming_audio_packet(self, drw_pkt): + payload = drw_pkt.get_drw_payload() + # Some firmwares prefix each audio chunk with the same 0x20-byte stream + # header used for video; strip it when present. + if payload.startswith(VIDEO_MARKER): + payload = payload[0x20:] + if not payload: + return + decode = CODECS[self.audio_codec][0] + try: + pcm = decode(payload) + except Exception: + logger.debug('Failed to decode audio chunk', exc_info=True) + return + await self.audio_buffer.publish(AudioFrame(idx=drw_pkt._cmd_idx, data=pcm)) + + async def get_audio_frame(self): + return await self.audio_buffer.get() + + async def start_audio(self): + if not self.is_audio_requested: + logger.info('%s: start audio', self.dev.dev_id) + await self.send_command(BinaryCommands.CMD_PEER_LIVEAUDIO_START) + self.is_audio_requested = True + + async def stop_audio(self): + if self.is_audio_requested: + self.is_audio_requested = False + await self.send_command(BinaryCommands.CMD_PEER_LIVEAUDIO_STOP) + + async def start_talk(self): + """Open the talk-back (speaker) channel.""" + logger.info('%s: start talk-back', self.dev.dev_id) + await self.send_command(BinaryCommands.CMD_LOCAL_LIVEAUDIO_START) + + async def stop_talk(self): + await self.send_command(BinaryCommands.CMD_LOCAL_LIVEAUDIO_STOP) + + async def send_audio(self, pcm): + """Send one chunk of signed 16-bit little-endian PCM to the camera + speaker (encoded with the session codec) on the audio DRW channel.""" + encode = CODECS[self.audio_codec][1] + payload = encode(pcm) + idx = self._outgoing_audio_idx & 0xFFFF + self._outgoing_audio_idx = (self._outgoing_audio_idx + 1) & 0xFFFF + await self.send(make_audio_drw_pkt(idx, payload)) + async def setup_device(self): auth = await self.login() self.dev_properties = await self.get_status() diff --git a/aiopppp/types.py b/aiopppp/types.py index c950b02..15776d5 100644 --- a/aiopppp/types.py +++ b/aiopppp/types.py @@ -51,3 +51,10 @@ class VideoFrame: def __init__(self, idx, data): self.idx = idx self.data = data + + +class AudioFrame: + def __init__(self, idx, data, sample_rate=8000): + self.idx = idx + self.data = data # signed 16-bit little-endian PCM + self.sample_rate = sample_rate From ff7b758269e1c5665a0a84601286186d3a9124cb Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:01:59 +0300 Subject: [PATCH 27/61] Binary: experimental CGI command vocabulary support Some A9/XD firmwares answer on the CGI (CB_*) command set rather than the BINCMD opcodes. Expose the CgiCommands vocabulary via send_cgi_command and a few convenience wrappers (reboot, IR, format SD, cam control). The exact on-wire distinction between the two vocabularies is not documented in the reference material, so these tunnel the CGI opcode through the standard binary DRW frame -- a reachable hook, marked experimental and unverified. Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/aiopppp/session.py b/aiopppp/session.py index a265cf8..d8b8a11 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -9,6 +9,7 @@ JSON_COMMAND_NAMES, PTZ, BinaryCommands, + CgiCommands, JsonCommands, PacketType, PtzDirection, @@ -1112,6 +1113,29 @@ async def send_audio(self, pcm): self._outgoing_audio_idx = (self._outgoing_audio_idx + 1) & 0xFFFF await self.send(make_audio_drw_pkt(idx, payload)) + # --- CGI command vocabulary ------------------------------------------- + # Some A9/XD firmwares answer on the CGI (CB_*) command set instead of the + # BinaryCommands (BINCMD) set. The on-wire distinction between the two + # vocabularies is not documented in the material this was built from, so + # these send the CGI opcode through the standard binary DRW frame. This is + # EXPERIMENTAL and may need adjusting against a CGI-firmware camera; it is + # provided as a hook so the CgiCommands vocabulary is reachable. + + async def send_cgi_command(self, cgi_cmd: CgiCommands, cmd_payload=b'', *, with_response=False): + return await self.send_command(cgi_cmd, cmd_payload, with_response=with_response) + + async def cgi_reboot(self): + await self.send_cgi_command(CgiCommands.CB_IEREBOOT) + + async def cgi_set_ir(self, value): + await self.send_cgi_command(CgiCommands.CB_IESET_IR, self._onoff_payload(value)) + + async def cgi_format_sd(self): + await self.send_cgi_command(CgiCommands.CB_IEFORMATSD) + + async def cgi_cam_control(self, payload=b''): + await self.send_cgi_command(CgiCommands.CB_CAM_CONTROL, payload) + async def setup_device(self): auth = await self.login() self.dev_properties = await self.get_status() From 34b3c2895ac21ffba05c3505be2f18381dee9412 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:03:27 +0300 Subject: [PATCH 28/61] Implement Device auto-reconnect with backoff on_device_lost was a TODO stub. When enable_reconnect is set, an unexpected session loss now schedules a background reconnect loop that retries connect() with exponential backoff (1s..30s) and resumes video if the caller had it streaming. close() sets a closing flag and cancels any in-flight reconnect so a deliberate close can't race it; connect() clears the flag so a Device can be reused after close. Co-Authored-By: Claude Opus 4.8 --- aiopppp/device.py | 55 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/aiopppp/device.py b/aiopppp/device.py index 1aab594..8dd6e0b 100644 --- a/aiopppp/device.py +++ b/aiopppp/device.py @@ -1,11 +1,14 @@ import asyncio import contextlib +import logging from .discover import Discovery from .exceptions import AlreadyConnectedError, NotConnectedError from .session import Session, make_session from .types import DeviceDescriptor +logger = logging.getLogger(__name__) + async def find_device(ip_address: str, timeout: int = 20) -> DeviceDescriptor: """Connect to the camera.""" @@ -51,10 +54,20 @@ def __init__(self, ip_address: str, username: str = '', password: str = '', # whenever video streaming starts or stops. self.on_video_state_change = on_video_state_change self.enable_reconnect = False + # Auto-reconnect bookkeeping. + self._reconnect_task = None + self._closing = False + # Whether the caller wants video, so a reconnect can resume streaming. + self._want_video = False + # Backoff bounds for reconnect attempts (seconds). + self.reconnect_min_delay = 1 + self.reconnect_max_delay = 30 async def connect(self, timeout: int = 15): if self.is_connected: raise AlreadyConnectedError("Already connected to the camera") + # Allow reuse of a Device that was previously close()d. + self._closing = False self.descriptor = await find_device(self.ip_address, timeout=timeout) @@ -108,10 +121,34 @@ async def connect(self, timeout: int = 15): def on_device_lost(self): # session is closed here self._session = None - if self.enable_reconnect: - # TODO - pass - # await self.find_device(timeout=timeout) + if self.enable_reconnect and not self._closing: + if self._reconnect_task is None or self._reconnect_task.done(): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + self._reconnect_task = loop.create_task(self._reconnect_loop()) + + async def _reconnect_loop(self): + """Re-establish the session after an unexpected loss, with exponential + backoff, resuming video if it was streaming.""" + delay = self.reconnect_min_delay + while self.enable_reconnect and not self._closing and not self.is_connected: + try: + await asyncio.sleep(delay) + if self._closing: + return + await self.connect() + logger.info('Reconnected to %s', self.ip_address) + if self._want_video: + await self.start_video() + return + except asyncio.CancelledError: + raise + except Exception as err: + logger.debug('Reconnect to %s failed (%s); retrying in %ss', + self.ip_address, err, delay) + delay = min(delay * 2, self.reconnect_max_delay) @property def is_connected(self): @@ -124,6 +161,14 @@ def session(self): return self._session async def close(self): + # Stop any in-flight reconnect first so it can't race a deliberate close. + self._closing = True + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._reconnect_task + self._reconnect_task = None + if self._session: await self._session.send_close_pkt() sess = self._session @@ -148,9 +193,11 @@ def is_video_requested(self): return self.session.is_video_requested async def start_video(self): + self._want_video = True return await self.session.start_video() async def stop_video(self): + self._want_video = False return await self.session.stop_video() async def get_video_frame(self): From d068112f5a2decaeacc4ffd313233a930b8713d8 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:08:38 +0300 Subject: [PATCH 29/61] Fix lost command result when the ACK arrives before the awaiter wait_cmd_result() looks the response future up by command key only after wait_ack() returns, but handle_incoming_command_packet() popped the future the moment the ACK arrived. A camera that answers a with_response command instantly (before wait_ack completes) therefore had its result discarded, and wait_cmd_result fell through to the empty default -- e.g. snapshot returning b''. It only worked for login/status because real cameras (and the simulator) delay those replies. Resolve the future in the handler without removing it, and pop it in wait_cmd_result after awaiting. Applied to both JSON and binary sessions. Co-Authored-By: Claude Opus 4.8 --- aiopppp/session.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index d8b8a11..487fa63 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -583,10 +583,12 @@ async def _request_video(self, mode): async def handle_incoming_command_packet(self, drw_pkt): if isinstance(drw_pkt, JsonCmdPkt): response = drw_pkt.json_payload - if response['cmd'] in self.cmd_waiters: - # logger.debug('Got awaited response %s', response) - self.cmd_waiters[response['cmd']].set_result(response) - del self.cmd_waiters[response['cmd']] + fut = self.cmd_waiters.get(response['cmd']) + # Resolve but don't remove the waiter here: wait_cmd_result looks it + # up by key *after* wait_ack, so popping now would lose a result that + # arrives before the caller starts awaiting it. + if fut is not None and not fut.done(): + fut.set_result(response) async def wait_cmd_result(self, cmd, timeout=5): return await self.call_with_error_check(self._wait_cmd_result(cmd, timeout)) @@ -594,7 +596,10 @@ async def wait_cmd_result(self, cmd, timeout=5): async def _wait_cmd_result(self, cmd, timeout=5): fut = self.cmd_waiters.get(cmd.value) if fut: - res = await asyncio.wait_for(fut, timeout=timeout) + try: + res = await asyncio.wait_for(fut, timeout=timeout) + finally: + self.cmd_waiters.pop(cmd.value, None) logger.debug('Got command result %s', res) return res return {'result': -1} @@ -752,10 +757,14 @@ async def handle_incoming_command_packet(self, drw_pkt): ) if drw_pkt.command in self.REV_ACKS: - waiter = self.cmd_waiters.pop(self.REV_ACKS[drw_pkt.command].value, None) - # logger.info(f'{drw_pkt.command=} {self.REV_ACKS[drw_pkt.command]=} {waiter=} {drw_pkt.cmd_payload=}') - if waiter: - waiter.set_result(drw_pkt.cmd_payload) + # Resolve but keep the waiter; wait_cmd_result pops it after + # awaiting. Popping here races an ACK that arrives during the + # preceding wait_ack, which would drop the result (e.g. a camera + # that answers a snapshot/status request instantly). + key = self.REV_ACKS[drw_pkt.command].value + fut = self.cmd_waiters.get(key) + if fut is not None and not fut.done(): + fut.set_result(drw_pkt.cmd_payload) async def send_command(self, cmd, cmd_payload=b'', *, with_response=False, **kwargs): pkt_idx = self.outgoing_command_idx @@ -776,7 +785,10 @@ async def send_command(self, cmd, cmd_payload=b'', *, with_response=False, **kwa async def wait_cmd_result(self, cmd, timeout=5): fut = self.cmd_waiters.get(cmd.value) if fut: - res = await asyncio.wait_for(fut, timeout=timeout) + try: + res = await asyncio.wait_for(fut, timeout=timeout) + finally: + self.cmd_waiters.pop(cmd.value, None) logger.debug('Got command result %s', res) return res return b'' From 039c1c11b6dbfe8fc6b2016c953e35aa1c8a6294 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:09:30 +0300 Subject: [PATCH 30/61] Extend the binary camera simulator for end-to-end testing The mock only answered auth + status (and returned an empty ticket, so login() actually failed against it). Extend it to: - return a populated session ticket so login() succeeds; - stream synthetic MJPEG video (0x55aa15a8-delimited frames) on LIVEVIDEO_START and stop on LIVEVIDEO_STOP, exercising the video reassembly/epoch path; - answer video-param get/set, IR/white-light, PTZ passthrough, snapshot (returns a JPEG), and reboot with the matching ACKs; - send command ACKs as proper BinaryCmdPkt frames; - become importable (guard asyncio.run under __main__). This drives the whole binary session end-to-end in software, which is how the binary-protocol branches were verified without hardware. Co-Authored-By: Claude Opus 4.8 --- binary_camera.py | 175 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 127 insertions(+), 48 deletions(-) diff --git a/binary_camera.py b/binary_camera.py index edc95c7..8e756bc 100644 --- a/binary_camera.py +++ b/binary_camera.py @@ -2,6 +2,7 @@ import struct import aiopppp.const +from aiopppp.const import BinaryCommands from aiopppp.packets import ( make_punch_pkt, make_p2palive_pkt, @@ -11,7 +12,24 @@ xq_bytes_decode, DrwPkt, ) -from aiopppp.types import DeviceID +from aiopppp.types import Channel, DeviceID + +VIDEO_MARKER = b'\x55\xaa\x15\xa8' + +# A minimal, structurally-valid JPEG (SOI ... EOI). Content is irrelevant to the +# protocol path; it just needs the FFD8..FFD9 envelope so consumers see a frame. +_MINI_JPEG = bytes.fromhex( + 'ffd8ffe000104a46494600010100000100010000' + 'ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c28372' + '92c30313434341f27393d38323c2e333432' + 'ffc0000b080010001001011100' + 'ffc4001f0000010501010101010100000000000000000102030405060708090a0b' + 'ffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552' + 'd1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a' + '737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9c' + 'ad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9fa' + 'ffda0008010100003f00fbd0ffd9' +) class UDPProtocol(asyncio.DatagramProtocol): @@ -45,8 +63,11 @@ def __init__(self): self.input = asyncio.Queue() self.output = asyncio.Queue() self.client_addr = None - self.ticket = b'abcd' + self.ticket = b'\x0e\xfc\xff\xff' self.cmd_idx = 1 + self.video_task = None + self.video_idx = 1 + self.frame_period = 0.2 # ~5 fps of synthetic frames def on_receive(self, data, addr): # print(f"Received {data} from {addr}") @@ -72,6 +93,28 @@ async def send_p2p_rdy_set(self): self.output.put_nowait((bytes(pkt), self.client_addr)) await asyncio.sleep(0.1) + _STATUS_BLOB = bytes.fromhex( + "0d 02 01 3d 74 0f 00 00 00 00 00 00 ff ff ff ff bf ff ff ff " + "01 01 00 30 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " + "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " + "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " + "00 00 00 00 00 00 00 00 00 01 00 00 02 00 00 00 00 00 00 00 " + "00 00 00 00 00 ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00 " + "00 00 00 00".replace(' ', '') + ) + + def _send_cmd_ack(self, ack_command, cmd_payload=b''): + self.output.put_nowait(( + bytes(BinaryCmdPkt( + cmd_idx=self.cmd_idx, + command=ack_command, + token=self.ticket, + cmd_payload=cmd_payload, + )), + self.client_addr, + )) + self.cmd_idx += 1 + async def process_drw(self, data): cmd_header_len = 12 pkt = parse_drw_pkt(data[4:]) @@ -84,55 +127,89 @@ async def process_drw(self, data): if len(data) > 4: data = xq_bytes_decode(data, 4) - if cmd_id == aiopppp.const.BinaryCommands.CMD_SYSTEM_USER_CHK: - INCORRECT_USER_RESP = '11 0a 20 11 0c 00 ff 00 00 00 00 00 57 56 6c 37 fe 01 01 01' - CORRECT_USER_RESP = '11 0a 20 11 04 00 ff 00 0e fc ff ff' - + if cmd_id == BinaryCommands.CMD_SYSTEM_USER_CHK: username, password = struct.unpack('<32s128s', data) username = username.decode('utf-8').strip('\x00') password = password.decode('utf-8').strip('\x00') - - resp = CORRECT_USER_RESP if username == 'admin' and password == 'admin' else INCORRECT_USER_RESP - - print('... BinaryCommand: cmd_id:', cmd_id, 'data:', data) - await asyncio.sleep(0.3) - print('... send ACK_SYSTEM_USER_CHK') - self.output.put_nowait((bytes( - DrwPkt(cmd_idx=0, channel=0, drw_payload=bytes.fromhex(resp)), - ), self.client_addr)) - # self.output.put_nowait((bytes(BinaryCmdPkt( - # cmd_idx=self.cmd_idx, - # command=aiopppp.const.BinaryCommands.ACK_SYSTEM_USER_CHK, - # ticket=self.ticket, - # cmd_payload=b'\xff\x00\x00\x00', - # )), self.client_addr)) - self.cmd_idx += 1 - elif cmd_id == aiopppp.const.BinaryCommands.CMD_SYSTEM_STATUS_GET: - await asyncio.sleep(0.3) - print('... send ACK_SYSTEM_STATUS_GET') - self.output.put_nowait( - ( - bytes( - BinaryCmdPkt( - cmd_idx=self.cmd_idx, - command=aiopppp.const.BinaryCommands.ACK_SYSTEM_STATUS_GET, - # token=b'\x0a\xfc\xff\xff', - token=b"\x00\x00\x00\x00", - # cmd_payload=bytes(range(0x15)), - cmd_payload=bytes.fromhex( - "0d 02 01 3d 74 0f 00 00 00 00 00 00 ff ff ff ff bf ff ff ff " - "01 01 00 30 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " - "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " - "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " - "00 00 00 00 00 00 00 00 00 01 00 00 02 00 00 00 00 00 00 00 " - "00 00 00 00 00 ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00 " - "00 00 00 00", - ), - ) - ), - self.client_addr, + print('... USER_CHK:', username, password) + await asyncio.sleep(0.1) + if username == 'admin' and password == 'admin': + # cmd_payload[4:8] is the session ticket the client will echo. + self._send_cmd_ack( + BinaryCommands.ACK_SYSTEM_USER_CHK, + b'\x00\x00\x00\x00' + self.ticket, ) - ) + else: + self._send_cmd_ack( + BinaryCommands.ACK_SYSTEM_USER_CHK, + bytes.fromhex('575660376fe010101'.rjust(16, '0')), + ) + elif cmd_id == BinaryCommands.CMD_SYSTEM_STATUS_GET: + await asyncio.sleep(0.1) + self._send_cmd_ack(BinaryCommands.ACK_SYSTEM_STATUS_GET, self._STATUS_BLOB) + elif cmd_id == BinaryCommands.CMD_PEER_VIDEOPARAM_SET: + self._send_cmd_ack(BinaryCommands.ACK_PEER_VIDEOPARAM_SET) + elif cmd_id == BinaryCommands.CMD_PEER_VIDEOPARAM_GET: + self._send_cmd_ack(BinaryCommands.ACK_PEER_VIDEOPARAM_GET, struct.pack('', data.hex(' ')) + self._send_cmd_ack(BinaryCommands.ACK_PEER_IRCUT_ONOFF) + elif cmd_id == BinaryCommands.CMD_PEER_LIGHTFILL_ONOFF: + print('... LIGHTFILL ->', data.hex(' ')) + self._send_cmd_ack(BinaryCommands.ACK_PEER_LIGHTFILL_ONOFF) + elif cmd_id == BinaryCommands.CMD_SNAPSHOT_GET: + self._send_cmd_ack(BinaryCommands.ACK_SNAPSHOT_GET, _MINI_JPEG) + elif cmd_id == BinaryCommands.CMD_SYSTEM_REBOOT: + print('... REBOOT requested') + self._send_cmd_ack(BinaryCommands.ACK_SYSTEM_REBOOT) + elif cmd_id == BinaryCommands.CMD_PASSTHROUGH_STRING_PUT: + print('... PTZ/passthrough ->', data.hex(' ')) + self._send_cmd_ack(BinaryCommands.ACK_PASSTHROUGH_STRING_PUT) + else: + print('... unhandled command:', cmd_id) + + def _start_video(self): + if self.video_task is None or self.video_task.done(): + print('... start video stream') + self.video_task = asyncio.create_task(self._stream_video()) + + def _stop_video(self): + if self.video_task and not self.video_task.done(): + print('... stop video stream') + self.video_task.cancel() + self.video_task = None + + def _next_video_idx(self): + idx = self.video_idx + self.video_idx = (self.video_idx + 1) & 0xFFFF + return idx + + def _send_video_chunk(self, chunk): + pkt = DrwPkt(channel=Channel.Video.value, cmd_idx=self._next_video_idx(), drw_payload=chunk) + self.output.put_nowait((bytes(pkt), self.client_addr)) + + async def _stream_video(self): + try: + while True: + # First chunk carries the 0x20-byte frame header (marker + pad); + # the client strips it and treats this index as a frame boundary. + header = VIDEO_MARKER + b'\x00' * (0x20 - len(VIDEO_MARKER)) + body = _MINI_JPEG + # Split into ~1024-byte payloads across several DRW chunks. + step = 1024 + parts = [body[i:i + step] for i in range(0, len(body), step)] or [b''] + self._send_video_chunk(header + parts[0]) + for part in parts[1:]: + self._send_video_chunk(part) + await asyncio.sleep(self.frame_period) + except asyncio.CancelledError: + raise async def on_packet(self, data, addr): @@ -167,4 +244,6 @@ async def main(): camera = BinaryCamera() await camera.run() -asyncio.run(main()) + +if __name__ == '__main__': + asyncio.run(main()) From 5634343c1fd702985afc384d0e338a48002081d4 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:22:38 +0300 Subject: [PATCH 31/61] Release 0.3.0 New binary-protocol features (explicit IR/light, snapshot, PTZ presets, full video params, system/network commands, SD playback, two-way audio, CGI vocabulary hook, device auto-reconnect) plus correctness fixes (video-channel-only epoch tracking, split command enums, bounded ACK waiters, command-result race, guarded packet parsing) and faster video reassembly. Co-Authored-By: Claude Opus 4.8 --- aiopppp/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopppp/__version__.py b/aiopppp/__version__.py index 3b5cc93..c9ee16f 100644 --- a/aiopppp/__version__.py +++ b/aiopppp/__version__.py @@ -1,2 +1,2 @@ -__version_tuple__ = version_tuple = (0, 2, 3) +__version_tuple__ = version_tuple = (0, 3, 0) __version__ = version = '.'.join(map(str, version_tuple)) From 7ee46e61ea5bdbbc55cd337507503a37a8b2b6a3 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:53:23 +0300 Subject: [PATCH 32/61] Add a transparent DID-rewriting proxy camera (binary protocol) proxy_camera.py sits between the app and a real binary-protocol camera: it advertises a configurable DID, forwards every packet to the real camera and every reply back, and rewrites only the DID (the app talks to the proxy DID, the camera to its real DID). The real DID is learned from the camera's discovery reply or given with --target-did. Each forwarded control packet is logged with its raw bytes and, when recognised, its decoded form (PunchPkt/P2pRdy DIDs, DRW command packets, etc.). Video and audio stream packets (and their ACKs) are relayed but never logged; keepalives are muted by default (--log-keepalive to show). Because the serial is carried as a uint64, leading zeros are dropped on the wire, so the proxy logs the effective DID to configure the app with. Only the binary protocol is supported (no transport encryption, so the DID is rewritten directly on the wire). Also parametrize binary_camera.py's listen port (and DID) so a camera, the proxy, and an app can run together locally; verified end-to-end with the simulator as the real camera (DID rewrite both ways, full session, snapshot, and no video in the log). Co-Authored-By: Claude Opus 4.8 --- binary_camera.py | 11 ++- proxy_camera.py | 226 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 proxy_camera.py diff --git a/binary_camera.py b/binary_camera.py index 8e756bc..3f7b0c3 100644 --- a/binary_camera.py +++ b/binary_camera.py @@ -57,9 +57,10 @@ async def create_udp_server(port, on_receive): class BinaryCamera: - def __init__(self): + def __init__(self, port=32108, dev_id=None): self.transport = None - self.dev_id = DeviceID('TEST',123456, 'CAMERA') + self.port = port + self.dev_id = dev_id or DeviceID('TEST',123456, 'CAMERA') self.input = asyncio.Queue() self.output = asyncio.Queue() self.client_addr = None @@ -234,14 +235,16 @@ async def receive_task(self): self.input.task_done() async def run(self): - self.transport = await create_udp_server(32108, self.on_receive) + self.transport = await create_udp_server(self.port, self.on_receive) out_t = asyncio.create_task(self.send_task()) in_t = asyncio.create_task(self.receive_task()) await asyncio.gather(*[out_t, in_t]) async def main(): - camera = BinaryCamera() + import sys + port = int(sys.argv[1]) if len(sys.argv) > 1 else 32108 + camera = BinaryCamera(port=port) await camera.run() diff --git a/proxy_camera.py b/proxy_camera.py new file mode 100644 index 0000000..26f0344 --- /dev/null +++ b/proxy_camera.py @@ -0,0 +1,226 @@ +"""Transparent PPPP proxy ("man-in-the-middle") camera for the binary protocol. + +Advertises a configurable DID to the app and forwards every packet to a real +camera (and its replies back), rewriting only the DID so the app talks to the +proxy while the proxy talks to the real device. Each forwarded control packet is +logged with its raw bytes and, when recognised, its decoded form. Video and +audio stream packets are relayed but never logged. + +Typical use (app configured with the proxy DID): + + python proxy_camera.py --did PROX-000001-CAMERA --target-ip 192.168.1.50 + +The app then discovers/connects to this host using PROX-000001-CAMERA, and all +traffic is relayed to the camera at 192.168.1.50 (whose real DID is learned from +its discovery reply, or given with --target-did). + +Only the binary protocol is supported: those cameras use no transport +encryption, so the DID can be rewritten directly on the wire. JSON (XOR1) +cameras would need decrypt/re-encrypt and are out of scope. +""" + +import argparse +import asyncio +import logging +import struct + +from aiopppp.const import CAM_MAGIC, PacketType +from aiopppp.packets import PunchPkt, parse_packet +from aiopppp.types import Channel, DeviceID + +logger = logging.getLogger('proxy_camera') + +# Packet types whose payload carries the 20-byte packed DID. +_DID_TYPES = { + PacketType.PunchPkt.value, + PacketType.P2pRdy.value, + PacketType.PunchTo.value, +} +_STREAM_CHANNELS = {Channel.Video.value, Channel.Audio.value} +_KEEPALIVE_TYPES = {PacketType.P2PAlive.value, PacketType.P2PAliveAck.value} + + +def parse_did(text): + """Parse a 'PREFIX-SERIAL-SUFFIX' DID string into a DeviceID.""" + parts = text.split('-') + if len(parts) < 3: + raise ValueError(f'Invalid DID {text!r}, expected PREFIX-SERIAL-SUFFIX') + prefix, serial, suffix = parts[0], parts[1], parts[2] + return DeviceID(prefix=prefix, serial=serial, suffix=suffix) + + +def pack_did(dev_id): + """Return the 20-byte on-wire form of a DID (as carried in PunchPkt).""" + return struct.pack( + '>4sQ8s', + dev_id.prefix.encode('ascii'), + int(dev_id.serial), + dev_id.suffix.encode('ascii'), + ) + + +class _EndpointProtocol(asyncio.DatagramProtocol): + def __init__(self, on_receive): + self._on_receive = on_receive + + def datagram_received(self, data, addr): + self._on_receive(data, addr) + + +class ProxyCamera: + """Relay between an app and a real binary-protocol camera, rewriting the DID.""" + + def __init__(self, proxy_did, target_ip, target_port=32108, + target_did=None, listen_host='0.0.0.0', listen_port=32108, + log_keepalive=False): + self.proxy_did = proxy_did + self.proxy_packed = pack_did(proxy_did) + self.target_ip = target_ip + self.camera_addr = (target_ip, target_port) + self.real_did = target_did + self.real_packed = pack_did(target_did) if target_did else None + self.listen_host = listen_host + self.listen_port = listen_port + self.log_keepalive = log_keepalive + + self.app_transport = None + self.camera_transport = None + # The app's current source address (differs between discovery and the + # session); replies are sent to the most recent one. + self.app_addr = None + + async def run(self): + loop = asyncio.get_running_loop() + # App-facing socket: the app discovers/connects here. + self.app_transport, _ = await loop.create_datagram_endpoint( + lambda: _EndpointProtocol(self._on_app_packet), + local_addr=(self.listen_host, self.listen_port), + allow_broadcast=True, + ) + # Camera-facing socket: we talk to the real camera from here. + self.camera_transport, _ = await loop.create_datagram_endpoint( + lambda: _EndpointProtocol(self._on_camera_packet), + remote_addr=self.camera_addr, + ) + logger.info('Proxy DID %s -> camera %s:%d (real DID %s)', + self.effective_proxy_did().dev_id, self.camera_addr[0], self.camera_addr[1], + self.real_did.dev_id if self.real_did else '') + # The serial travels as a uint64, so leading zeros are dropped on the + # wire. Tell the user the exact DID to configure the app with. + effective = self.effective_proxy_did().dev_id + if effective != self.proxy_did.dev_id: + logger.info('NOTE: configure the app with DID %s (serial leading zeros are dropped)', + effective) + else: + logger.info('Configure the app with DID %s', effective) + logger.info('Listening for the app on %s:%d', self.listen_host, self.listen_port) + # Run until cancelled. + await asyncio.Event().wait() + + # -- packet handlers ---------------------------------------------------- + + def _on_app_packet(self, data, addr): + self.app_addr = addr + self._log('APP->CAM', data) + forwarded = self._rewrite(data, self.proxy_packed, self.real_packed) + self.camera_transport.sendto(forwarded) + + def _on_camera_packet(self, data, addr): + # Learn the real DID from the camera's first PunchPkt so app->camera + # DID rewriting works even without --target-did. + if self.real_packed is None and len(data) >= 2 and data[1] == PacketType.PunchPkt.value: + self._learn_real_did(data) + self._log('CAM->APP', data) + forwarded = self._rewrite(data, self.real_packed, self.proxy_packed) + if self.app_addr is not None: + self.app_transport.sendto(forwarded, self.app_addr) + + def _learn_real_did(self, data): + try: + self.real_did = PunchPkt(PacketType.PunchPkt, data[4:]).as_object() + self.real_packed = pack_did(self.real_did) + logger.info('Learned real camera DID: %s', self.real_did.dev_id) + except Exception: + logger.debug('Could not parse camera DID from PunchPkt', exc_info=True) + + # -- helpers ------------------------------------------------------------ + + def effective_proxy_did(self): + """The proxy DID as it appears on the wire (serial normalized to uint64).""" + return PunchPkt(PacketType.PunchPkt, self.proxy_packed).as_object() + + @staticmethod + def _rewrite(data, old_packed, new_packed): + """Return data with the DID rewritten, only in DID-bearing packets.""" + if not old_packed or not new_packed or old_packed == new_packed: + return data + if len(data) >= 2 and data[1] in _DID_TYPES and old_packed in data: + return data.replace(old_packed, new_packed) + return data + + @staticmethod + def _is_stream(data): + """True for video/audio DRW (and their ACKs), which must not be logged.""" + if len(data) < 6 or data[0] != CAM_MAGIC: + return False + if data[1] in (PacketType.Drw.value, PacketType.DrwAck.value): + return data[5] in _STREAM_CHANNELS + return False + + def _log(self, direction, data): + if self._is_stream(data): + return + if not self.log_keepalive and len(data) >= 2 and data[1] in _KEEPALIVE_TYPES: + return + try: + decoded = str(parse_packet(data)) + except Exception: + typ = f'0x{data[1]:02x}' if len(data) >= 2 else '??' + decoded = f'' + logger.info('%s | %s', direction, decoded) + logger.info('%s | raw: %s', direction, data.hex(' ')) + + +def _build_arg_parser(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument('--did', required=True, + help='DID to advertise to the app (PREFIX-SERIAL-SUFFIX)') + p.add_argument('--target-ip', required=True, + help='IP (or broadcast address) of the real camera') + p.add_argument('--target-port', type=int, default=32108, + help='UDP port of the real camera (default 32108)') + p.add_argument('--target-did', default=None, + help="Real camera DID; if omitted it is learned from the camera's " + 'discovery reply') + p.add_argument('--listen-host', default='0.0.0.0', + help='Local address to listen on for the app (default 0.0.0.0)') + p.add_argument('--listen-port', type=int, default=32108, + help='Local UDP port to listen on for the app (default 32108)') + p.add_argument('--log-keepalive', action='store_true', + help='Also log P2PAlive/P2PAliveAck keepalives (noisy)') + p.add_argument('--log-level', default='INFO') + return p + + +async def main(argv=None): + args = _build_arg_parser().parse_args(argv) + logging.basicConfig(level=getattr(logging, args.log_level.upper(), logging.INFO), + format='%(message)s') + proxy = ProxyCamera( + proxy_did=parse_did(args.did), + target_ip=args.target_ip, + target_port=args.target_port, + target_did=parse_did(args.target_did) if args.target_did else None, + listen_host=args.listen_host, + listen_port=args.listen_port, + log_keepalive=args.log_keepalive, + ) + await proxy.run() + + +if __name__ == '__main__': + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass From dc515180c20dbdf5255ee41e32762b052fdc1cf6 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:41:58 +0300 Subject: [PATCH 33/61] Rework test web UI: per-camera pages + full binary control surface - Index is now a lightweight list of links (auto-refreshing); each camera gets its own /camera/{dev_id} page instead of one page streaming all. - New routes: GET /{dev}/snapshot (JPEG still), /{dev}/params (decoded video-param read-back), /{dev}/info (status + system/network blocks), /{dev}/audio (live G.711 as streaming WAV). - New commands: PTZ preset goto/save, set-alias, sync-datetime, start/stop-audio, talk-test (1 s 440 Hz tone via talk-back). - Command errors now return JSON with the exception and are shown in a status line -- server-side failures were previously silent. - Dropdowns pre-select from the camera's reported values on page load. Covers ENH-001..ENH-005 from BUGS.md (test tool only, no protocol changes). Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/http_server.py | 455 ++++++++++++++++++++++++++++++++--------- 1 file changed, 361 insertions(+), 94 deletions(-) diff --git a/aiopppp/http_server.py b/aiopppp/http_server.py index 97aacdf..0698c18 100644 --- a/aiopppp/http_server.py +++ b/aiopppp/http_server.py @@ -1,131 +1,394 @@ import asyncio +import functools import logging +import math +import struct import uuid from aiohttp import web +from .const import VideoParamType, VideoResolution, VideoRotate + logger = logging.getLogger(__name__) SESSIONS = {} +# Parameters shown in the per-camera "current values" readout. Maps the +# symbolic name to the enum used to prettify the raw value (None = plain int). +READBACK_PARAMS = [ + ('resolution', VideoResolution, 'VIDEO_RESOLUTION_'), + ('rotate', VideoRotate, 'VIDEO_ROTATE_'), + ('bitrate', None, ''), +] + +# 1 s of 440 Hz sine at 8 kHz signed 16-bit LE -- test tone for talk-back. +_TONE_PCM = b''.join( + struct.pack('{x}' for x in SESSIONS.keys() + ) or '
  • no cameras discovered yet
  • ' + return web.Response( + text=( + 'PPPP Cameras' + '' + '

    PPPP Cameras

    ' + f'
      {cameras}
    ' + '

    Page refreshes every 5 s as discovery finds cameras.

    ' + '' + ), + headers={'content-type': 'text/html'}, + ) + + +def _camera_page_html(dev_id): js = ''' ''' - videos = '
    '.join( - f'

    {x}


    ' - f'' - f'' - f'' - f'' - '
    ' - f'' - f'' - f'' - f'' - f'' - '
    ' - f'' - f'' + x = dev_id + body = ( + f'

    ← all cameras

    ' + f'

    {x}

    ' + '
    ' + + f'
    ' + f'' + f'' + f'' + + '

    PTZ

    ' + f'' + f'' + f'' + f'' + f'' + '   Preset: ' + '' + '' + + '

    Lights

    ' + f'' + f'' + f'' + f'' + + '

    Video parameters

    ' + '
    not read yet
    ' + '
    ' ' Resolution: ' - f'' + '' '' ' Rotate: ' - f'' + '' '' - # '
    ' - # ' Brightness: ' - # f'' - # 'Contrast: ' - # f'' - # ' Saturation: ' - # f'' - # ' Sharpness: ' - # f'' - # 'Framerate: ' - # f'' ' Bitrate: ' - f'' - # '
    ' - # f'' - # f'' - # f'' - # f'' - # f'' - # f'' - '
    ' - f'' - for x in SESSIONS.keys()) + '' + + '

    Audio

    ' + f' ' + f'' + f'' + + '

    System

    ' + ' ' + 'Alias: ' + ' ' + f' ' + f'' + '
    '
    +    )
    +    return (
    +        '{}{}{}'.format(dev_id, js, body)
    +    )
    +
    +
    +async def camera_page(request):
    +    session, err = _get_session(request)
    +    if err:
    +        return err
         return web.Response(
    -        text="PPPP Cameras{}

    PPPP Cameras

    {}".format( - js, - videos, - ), + text=_camera_page_html(request.match_info['dev_id']), headers={'content-type': 'text/html'}, ) async def handle_commands(request): - dev_id_str = request.match_info['dev_id'] + session, err = _get_session(request) + if err: + return err cmd = request.match_info['cmd'] params = await request.json() - if dev_id_str not in SESSIONS: - return web.Response( - text='{"status": "error", "message": "unknown device"}', - headers={'content-type': 'application/json'}, - status=404, - ) - session = SESSIONS[dev_id_str] + + async def talk_test(**kwargs): + # 1 s test tone in 40 ms chunks (320 samples = 640 PCM bytes), paced + # in real time so the camera's jitter buffer isn't flooded. + await session.start_talk() + try: + for i in range(0, len(_TONE_PCM), 640): + await session.send_audio(_TONE_PCM[i:i + 640]) + await asyncio.sleep(0.04) + finally: + await session.stop_talk() + + async def sync_datetime(**kwargs): + await session.set_datetime() + web2cmd = { - 'toggle-lamp': session.toggle_whitelight, - 'toggle-ir': session.toggle_ir, - 'rotate': session.step_rotate, - 'rotate-stop': session.rotate_stop, - 'reboot': session.reboot, - 'start-video': session.start_video, - 'stop-video': session.stop_video, - 'set-video-param': session.set_video_param, - # 'reset': session.reset, - }.get(cmd) - - if web2cmd is None: - return web.Response( - text='{"status": "error", "message": "unknown command"}', - headers={'content-type': 'application/json'}, - status=404, - ) - - await web2cmd(**params) - return web.Response(text='{"status": "ok"}', headers={'content-type': 'application/json'}) + 'toggle-lamp': getattr(session, 'toggle_whitelight', None), + 'toggle-ir': getattr(session, 'toggle_ir', None), + 'rotate': getattr(session, 'step_rotate', None), + 'rotate-stop': getattr(session, 'rotate_stop', None), + 'reboot': getattr(session, 'reboot', None), + 'start-video': getattr(session, 'start_video', None), + 'stop-video': getattr(session, 'stop_video', None), + 'set-video-param': getattr(session, 'set_video_param', None), + 'ptz-preset-goto': getattr(session, 'ptz_goto_preset', None), + 'ptz-preset-set': getattr(session, 'ptz_set_preset', None), + 'set-alias': getattr(session, 'set_alias', None), + 'sync-datetime': sync_datetime if hasattr(session, 'set_datetime') else None, + 'start-audio': getattr(session, 'start_audio', None), + 'stop-audio': getattr(session, 'stop_audio', None), + 'talk-test': talk_test if hasattr(session, 'start_talk') else None, + } + + if cmd not in web2cmd: + return _json_error('unknown command', 404) + handler = web2cmd[cmd] + if handler is None: + return _json_error('command not supported by this device', 501) + + try: + await handler(**params) + except Exception as e: + # Surface the failure to the browser -- a silent 500 here makes a + # server-side error indistinguishable from "camera ignored it". + logger.exception('Command %s failed for %s', cmd, request.match_info['dev_id']) + return _json_error(f'{type(e).__name__}: {e}', 500) + return web.json_response({'status': 'ok'}) + + +async def get_params(request): + """Read back current video parameters (ENH-003). Values are best-effort + decoded; the raw ACK payload is always included.""" + session, err = _get_session(request) + if err: + return err + if not hasattr(session, 'get_video_param'): + return _json_error('not supported by this device', 501) + + result = {} + # Sequential on purpose: wait_cmd_result is keyed by command, concurrent + # VIDEOPARAM_GETs would race each other. + for name, enum_cls, prefix in READBACK_PARAMS: + try: + payload = await session.get_video_param(name, timeout=3) + except Exception as e: + result[name] = {'error': f'{type(e).__name__}: {e}'} + continue + expected = VideoParamType[f'VIDEO_PARAM_TYPE_{name.upper()}'].value + value = None + if len(payload) >= 8: + p, v = struct.unpack_from('= 4: + value = struct.unpack_from(' Date: Sun, 23 Aug 2026 16:48:05 +0300 Subject: [PATCH 34/61] Tag every session log line with its device ID + no-boundary probe With several cameras connected, session.py logged everything through one untagged module logger -- handle_drw/completeness/recv< lines from four concurrent sessions were indistinguishable, which made per-device debugging (BUG-002) impossible and led to misreading interleaved DRW indices from different cameras as one corrupt stream. - SessionLogAdapter prefixes '[]' on all session logging; messages that embedded the dev id explicitly are de-duplicated. - New diagnostic: if video chunks keep arriving with no VIDEO_MARKER frame boundary (100/1000/10000 thresholds), warn with a hex sample of the payload head -- pinpoints cameras that use a different frame framing. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 130 ++++++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 55 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 487fa63..bd808b9 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -43,6 +43,14 @@ VIDEO_MARKER = b'\x55\xaa\x15\xa8' +class SessionLogAdapter(logging.LoggerAdapter): + """Tag every session log line with the device ID. Several cameras log + through this module concurrently; untagged lines are unattributable.""" + + def process(self, msg, kwargs): + return f'[{self.extra["dev"]}] {msg}', kwargs + + class State(Enum): DISCONNECTED = 0 CONNECTED = 1 @@ -97,6 +105,10 @@ def __init__(self, *args, **kwargs): # chunk instead of an O(frame) rescan (which was O(frame^2) per frame). self._frame_window = (None, None) self._frame_missing = set() + # Chunks seen since the last frame-boundary header. If this grows large + # the camera is streaming video we can't frame (e.g. a different marker + # than VIDEO_MARKER) -- log a sample so the format can be identified. + self._chunks_since_boundary = 0 async def process_video_queue(self): while True: @@ -108,16 +120,23 @@ def start_video_queue(self): async def handle_incoming_video_packet(self, pkt_epoch, pkt): video_payload = pkt.get_drw_payload() - # logger.info(f'- video frame {pkt._cmd_idx}') + # self.log.info(f'- video frame {pkt._cmd_idx}') video_chunk_idx = pkt._cmd_idx + 0x10000 * pkt_epoch # 0x20 - size of the header starting with this magic if video_payload.startswith(VIDEO_MARKER): + self._chunks_since_boundary = 0 self.video_boundaries.add(video_chunk_idx) self.video_received[video_chunk_idx] = video_payload[0x20:] else: self.video_received[video_chunk_idx] = video_payload + self._chunks_since_boundary += 1 + if self._chunks_since_boundary in (100, 1000, 10000): + self.log.warning( + 'No frame boundary in %d video chunks; payload head: [%s]', + self._chunks_since_boundary, video_payload[:32].hex(' '), + ) await self.process_video_frame(video_chunk_idx) async def process_video_frame(self, new_idx=None): @@ -149,12 +168,12 @@ async def process_video_frame(self, new_idx=None): data = b''.join(self.video_received[i] for i in range(index, last_index)) await self.frame_buffer.publish(VideoFrame(idx=index, data=data)) - if logger.isEnabledFor(logging.DEBUG): + if self.log.isEnabledFor(logging.DEBUG): completeness = ''.join( 'x' if i in self.video_received else '_' for i in range(index, last_index) ) - logger.debug('.. completeness: %s', completeness) + self.log.debug('.. completeness: %s', completeness) class Session(PacketQueueMixin, VideoQueueMixin): @@ -169,6 +188,7 @@ def __init__(self, dev, on_disconnect, *args, on_video_state_change=None, **kwar self.state = State.DISCONNECTED self.dev = dev + self.log = SessionLogAdapter(logger, {'dev': dev.dev_id.dev_id}) self.dev_properties = {} self.outgoing_command_idx = 0 self.transport = None @@ -213,10 +233,10 @@ def on_receive(self, data): # One malformed datagram must never raise out of the asyncio # datagram callback (which would spam "Exception in callback" and, # in the worst case, wedge the transport). Log and drop it. - logger.debug('Dropping undecodable datagram (%d bytes): [%s]', len(data), data[:16].hex(' ')) + self.log.debug('Dropping undecodable datagram (%d bytes): [%s]', len(data), data[:16].hex(' ')) return - # logger.debug(f"recv< {pkt} {pkt.get_payload()}") - logger.debug(f"recv< {pkt.type}, len={len(pkt.get_payload())}") + # self.log.debug(f"recv< {pkt} {pkt.get_payload()}") + self.log.debug(f"recv< {pkt.type}, len={len(pkt.get_payload())}") self.packet_queue.put_nowait(pkt) async def call_with_error_check(self, coro): @@ -238,7 +258,7 @@ async def send(self, pkt): MAX_DRW_WAITERS = 256 async def _send(self, pkt): - logger.debug(f"send> {pkt}") + self.log.debug(f"send> {pkt}") if pkt.type == PacketType.Drw: existing = self.drw_waiters.get(pkt._cmd_idx) if existing is not None and not existing.done(): @@ -268,14 +288,14 @@ async def handle_incoming_packet(self, pkt): elif pkt.type == PacketType.Drw: await self.handle_drw(pkt) elif pkt.type == PacketType.DrwAck: - logger.debug(f'Got DRW ACK {pkt}') + self.log.debug(f'Got DRW ACK {pkt}') await self.handle_drw_ack(pkt) elif pkt.type == PacketType.P2PAliveAck: - logger.debug(f'Got P2PAlive ACK {pkt}') + self.log.debug(f'Got P2PAlive ACK {pkt}') elif pkt.type == PacketType.Close: await self.handle_close(pkt) else: - logger.warning(f'Got UNKNOWN {pkt}') + self.log.warning(f'Got UNKNOWN {pkt}') async def login(self): pass @@ -283,7 +303,7 @@ async def login(self): async def start_video(self): await self.device_is_ready.wait() if not self.is_video_requested: - logger.info('Start video') + self.log.info('Start video') self.last_drw_pkt_at = datetime.datetime.now() await self._request_video(1) self.is_video_requested = True @@ -311,7 +331,7 @@ async def _request_video(self, mode): pass async def handle_drw(self, drw_pkt): - logger.debug('handle_drw(idx=%s, chn=%s)', drw_pkt._cmd_idx, drw_pkt._channel) + self.log.debug('handle_drw(idx=%s, chn=%s)', drw_pkt._cmd_idx, drw_pkt._channel) await self.send(make_drw_ack_pkt(drw_pkt)) self.last_drw_pkt_at = datetime.datetime.now() @@ -323,14 +343,14 @@ async def handle_drw(self, drw_pkt): # 0x10000 index shift. pkt_epoch = self._get_drw_epoch(drw_pkt) if pkt_epoch > self.video_epoch: - logger.info('Video epoch changed %s -> %s', self.video_epoch, pkt_epoch) + self.log.info('Video epoch changed %s -> %s', self.video_epoch, pkt_epoch) self.video_epoch = pkt_epoch self.last_drw_pkt_idx = drw_pkt._cmd_idx elif self.last_drw_pkt_idx < drw_pkt._cmd_idx: self.last_drw_pkt_idx = drw_pkt._cmd_idx if self.video_stale_at: - logger.warning('Got video data while stale') + self.log.warning('Got video data while stale') self.video_stale_at = None self.video_chunk_queue.put_nowait((pkt_epoch, drw_pkt)) elif drw_pkt._channel == Channel.Audio: @@ -364,7 +384,7 @@ def _reset_cmd_waiter(self, cmd): async def handle_drw_ack(self, pkt): cmd_idx_ack = int.from_bytes(pkt.get_payload()[4:6], 'big') - logger.debug('handle_drw_ack(idx=%s)', cmd_idx_ack) + self.log.debug('handle_drw_ack(idx=%s)', cmd_idx_ack) fut = self.drw_waiters.pop(cmd_idx_ack, None) if fut is not None and not fut.done(): fut.set_result(pkt) @@ -377,16 +397,16 @@ async def _wait_ack(self, idx, timeout=5): raise ValueError('Need to provide numeric command index') fut = self.drw_waiters.get(idx) if fut: - logger.debug(f'Waiting for ACK for {idx}') + self.log.debug(f'Waiting for ACK for {idx}') try: await asyncio.wait_for(fut, timeout=timeout) - logger.debug('wait_ack(idx=%d) complete, waiters: %d', idx, len(self.drw_waiters)) + self.log.debug('wait_ack(idx=%d) complete, waiters: %d', idx, len(self.drw_waiters)) except asyncio.TimeoutError: self.drw_waiters.pop(idx, None) raise async def handle_close(self, pkt): - logger.info('%s requested close', self.dev.dev_id) + self.log.info('peer requested close') self._on_device_lost() async def setup_device(self): @@ -409,16 +429,16 @@ async def _run(self): # handshake (common when it is flaky/half-wedged). Treat it as a # lost device rather than letting an unhandled exception escape # and take the whole process down. - logger.warning('%s did not become ready (no P2pRdy), disconnecting', self.dev.dev_id) + self.log.warning('did not become ready (no P2pRdy), disconnecting') await self.send_close_pkt() self._on_device_lost() return - logger.info('Connected to %s at %s, json=%s', self.dev.dev_id, self.dev.addr, self.dev.is_json) + self.log.info('Connected at %s, json=%s', self.dev.addr, self.dev.is_json) self.state = State.CONNECTED try: await self.setup_device() except asyncio.TimeoutError: - logger.error('Timeout during device setup') + self.log.error('Timeout during device setup') await self.send_close_pkt() self._on_device_lost() return @@ -428,13 +448,13 @@ async def _run(self): await asyncio.sleep(1) except asyncio.CancelledError: if self.transport: - logger.debug('Session main task cancelled, sending close packet') + self.log.debug('Session main task cancelled, sending close packet') await self.send_close_pkt() raise except Exception: # A single session must never crash the whole process. Log it, tear # the session down, and let discovery/HA reconnect. - logger.exception('Session for %s failed; disconnecting', self.dev.dev_id) + self.log.exception('Session failed; disconnecting') try: await self.send_close_pkt() except Exception: @@ -448,7 +468,7 @@ async def _run(self): VIDEO_DEAD_SEC = 10 async def loop_step(self): - logger.debug(f"iterate in Session for {self.dev.dev_id}") + self.log.debug("iterate in Session") now = datetime.datetime.now() # Video liveness. Applies to both protocols: a binary camera that keeps @@ -460,16 +480,16 @@ async def loop_step(self): (now - self.last_drw_pkt_at).total_seconds() > self.VIDEO_REREQUEST_SEC ): self.video_stale_at = self.last_drw_pkt_at - logger.info('No video for %ds. Re-requesting video', self.VIDEO_REREQUEST_SEC) + self.log.info('No video for %ds. Re-requesting video', self.VIDEO_REREQUEST_SEC) await self._request_video(1) if self.video_stale_at and (now - self.video_stale_at).total_seconds() > self.VIDEO_DEAD_SEC: - logger.warning('No video for %ds. Disconnecting', self.VIDEO_DEAD_SEC) + self.log.warning('No video for %ds. Disconnecting', self.VIDEO_DEAD_SEC) await self.send_close_pkt() self._on_device_lost() return if (now - self.last_recv_at).total_seconds() > self.RECV_TIMEOUT_SEC: - logger.warning( + self.log.warning( 'No packets from %s for %ds: connection is dead, disconnecting', self.dev.dev_id, self.RECV_TIMEOUT_SEC, ) @@ -478,7 +498,7 @@ async def loop_step(self): return if (now - self.last_alive_pkt_at).total_seconds() > 10: self.last_alive_pkt_at = now - logger.info('Send P2PAlive') + self.log.info('Send P2PAlive') await self.send(make_p2palive_pkt()) def start(self): @@ -492,7 +512,7 @@ def running_tasks(self): return tuple(x for x in (self.main_task, self.process_packet_task, self.process_video_task) if x) def _on_device_lost(self): - logger.warning('Device %s lost', self.dev.dev_id) + self.log.warning('Device lost') self.stop() if self.on_disconnect: self.on_disconnect(self.dev) @@ -505,7 +525,7 @@ def stop(self): # (e.g. P2pRdy timeout) is still DISCONNECTED but has a live transport # and queue tasks, so we must fall through and clean those up. return - logger.info('Stopping task for %s', self.dev.dev_id) + self.log.info('Stopping session tasks') self.device_is_ready.set() reassert_task = getattr(self, '_reassert_task', None) if reassert_task and not reassert_task.done(): @@ -577,7 +597,7 @@ async def login(self): return True async def _request_video(self, mode): - logger.info('Request video %s', mode) + self.log.info('Request video %s', mode) await self.send_command(JsonCommands.CMD_STREAM, video=mode) async def handle_incoming_command_packet(self, drw_pkt): @@ -600,14 +620,14 @@ async def _wait_cmd_result(self, cmd, timeout=5): res = await asyncio.wait_for(fut, timeout=timeout) finally: self.cmd_waiters.pop(cmd.value, None) - logger.debug('Got command result %s', res) + self.log.debug('Got command result %s', res) return res return {'result': -1} async def setup_device(self): auth = await self.login() idx = await self.send_command(JsonCommands.CMD_GET_PARMS, with_response=True) - # logger.debug('Waiting for params ack') + # self.log.debug('Waiting for params ack') await self.wait_ack(idx) # { @@ -632,7 +652,7 @@ async def setup_device(self): del cam_properties[f] self.dev_properties = cam_properties self.dev_properties['auth'] = auth - logger.info('Camera properties: %s', cam_properties) + self.log.info('Camera properties: %s', cam_properties) self.device_is_ready.set() async def control(self, no_ack=False, **kwargs): @@ -644,24 +664,24 @@ async def toggle_lamp(self, value): await self.control(lamp=1 if value else 0) async def toggle_whitelight(self, value, **kwargs): - logger.info('%s: toggle white light = %s', self.dev.dev_id, value) + self.log.info('toggle white light = %s', value) idx = await self.send_command(JsonCommands.CMD_SET_WHITELIGHT, status=value) await self.wait_ack(idx) async def toggle_ir(self, value): - logger.info('%s: toggle IR = %s', self.dev.dev_id, value) + self.log.info('toggle IR = %s', value) # control() already waits for the ACK; it returns None, so the previous # `await self.wait_ack(idx)` raised ValueError on every call. await self.control(icut=1 if value else 0) async def rotate_start(self, value): - logger.info('%s: rotate_start %s', self.dev.dev_id, value) + self.log.info('rotate_start %s', value) value = PTZ[f'{value.upper()}_START'].value idx = await self.send_command(JsonCommands.CMD_PTZ_CONTROL, parms=0, value=value) await self.wait_ack(idx) async def rotate_stop(self, **kwargs): - logger.info('%s: rotate_stop', self.dev.dev_id) + self.log.info('rotate_stop') indexes = [] for value in [PTZ.LEFT_STOP, PTZ.RIGHT_STOP, PTZ.DOWN_STOP, PTZ.UP_STOP]: indexes.append(await self.send_command(JsonCommands.CMD_PTZ_CONTROL, parms=0, value=value.value)) @@ -675,7 +695,7 @@ async def step_rotate(self, value): await self.rotate_stop() async def reboot(self, **kwargs): - logger.info('%s: reboot', self.dev.dev_id) + self.log.info('reboot') await self.control(reboot=1, no_ack=True) async def reset(self, **kwargs): @@ -747,7 +767,7 @@ async def handle_incoming_command_packet(self, drw_pkt): if drw_pkt.command == BinaryCommands.ACK_SYSTEM_USER_CHK and len(drw_pkt.cmd_payload) > 0: # this is from cam-reverse code self.ticket = drw_pkt.cmd_payload[4:8] - logger.debug( + self.log.debug( 'handle_incoming_command_packet: token=%s, ticket=%s, %s data=%s (%s)', drw_pkt.token.hex(), self.ticket.hex(), @@ -789,7 +809,7 @@ async def wait_cmd_result(self, cmd, timeout=5): res = await asyncio.wait_for(fut, timeout=timeout) finally: self.cmd_waiters.pop(cmd.value, None) - logger.debug('Got command result %s', res) + self.log.debug('Got command result %s', res) return res return b'' @@ -825,7 +845,7 @@ def _get_video_params(mode): return [BinarySession._build_video_param(*x) for x in pairs[mode]] async def _request_video(self, mode): - logger.info('Request video %s', mode) + self.log.info('Request video %s', mode) if mode == 1: video_params = self._get_video_params(3) @@ -856,13 +876,13 @@ async def _reassert_video_params(self, video_params, delay=5): await asyncio.sleep(delay) if not self.is_video_requested or self.transport is None: return - logger.info('%s: re-asserting video params to lock resolution', self.dev.dev_id) + self.log.info('re-asserting video params to lock resolution') for video_param in video_params: await self.send_command(BinaryCommands.CMD_PEER_VIDEOPARAM_SET, video_param) except asyncio.CancelledError: raise except Exception: - logger.debug('Re-assert video params failed', exc_info=True) + self.log.debug('Re-assert video params failed', exc_info=True) @staticmethod def _build_video_param(param_type, value): @@ -945,11 +965,11 @@ async def login(self): idx = await self.send_command(BinaryCommands.CMD_SYSTEM_USER_CHK, payload, with_response=True) await self.wait_ack(idx) auth_result = await self.wait_cmd_result(BinaryCommands.CMD_SYSTEM_USER_CHK) - logger.debug(f"Connect user responded with {auth_result=}") + self.log.debug(f"Connect user responded with {auth_result=}") if auth_result == b'': #some functions of the camera (like video and ptz) may be available even without login #raise AuthError(f'Login failed: [{auth_result.hex(" ")}]') - logger.error(f'Login failed: [{auth_result.hex(" ")}]') + self.log.error(f'Login failed: [{auth_result.hex(" ")}]') return False return True @@ -1043,7 +1063,7 @@ def _playback_payload(filename='', offset=0): async def playback_start(self, filename, offset=0): """Start playback of an SD recording. Playback video is delivered on the normal video channel, so frames arrive through get_video_frame().""" - logger.info('%s: playback start %r @ %s', self.dev.dev_id, filename, offset) + self.log.info('playback start %r @ %s', filename, offset) self.last_drw_pkt_at = datetime.datetime.now() await self.send_command( BinaryCommands.CMD_PEER_PLAYBACK_START, self._playback_payload(filename, offset), @@ -1090,7 +1110,7 @@ async def handle_incoming_audio_packet(self, drw_pkt): try: pcm = decode(payload) except Exception: - logger.debug('Failed to decode audio chunk', exc_info=True) + self.log.debug('Failed to decode audio chunk', exc_info=True) return await self.audio_buffer.publish(AudioFrame(idx=drw_pkt._cmd_idx, data=pcm)) @@ -1099,7 +1119,7 @@ async def get_audio_frame(self): async def start_audio(self): if not self.is_audio_requested: - logger.info('%s: start audio', self.dev.dev_id) + self.log.info('start audio') await self.send_command(BinaryCommands.CMD_PEER_LIVEAUDIO_START) self.is_audio_requested = True @@ -1110,7 +1130,7 @@ async def stop_audio(self): async def start_talk(self): """Open the talk-back (speaker) channel.""" - logger.info('%s: start talk-back', self.dev.dev_id) + self.log.info('start talk-back') await self.send_command(BinaryCommands.CMD_LOCAL_LIVEAUDIO_START) async def stop_talk(self): @@ -1152,7 +1172,7 @@ async def setup_device(self): auth = await self.login() self.dev_properties = await self.get_status() self.dev_properties['auth'] = auth - logger.info('Camera properties: %s', self.dev_properties) + self.log.info('Camera properties: %s', self.dev_properties) self.device_is_ready.set() @staticmethod @@ -1170,11 +1190,11 @@ async def reset(self, **kwargs): await self.send_command(BinaryCommands.CMD_SYSTEM_DFTCFG_RECOVERY) async def toggle_whitelight(self, value, **kwargs): - logger.info('%s: white light = %s', self.dev.dev_id, value) + self.log.info('white light = %s', value) await self.send_command(BinaryCommands.CMD_PEER_LIGHTFILL_ONOFF, self._onoff_payload(value)) async def toggle_ir(self, value, **kwargs): - logger.info('%s: IR = %s', self.dev.dev_id, value) + self.log.info('IR = %s', value) # IR is also settable through the video-param channel; the dedicated # ONOFF command is the direct equivalent of the app's night-mode switch. await self.send_command(BinaryCommands.CMD_PEER_IRCUT_ONOFF, self._onoff_payload(value)) @@ -1206,13 +1226,13 @@ async def step_rotate(self, value, **kwargs): async def ptz_goto_preset(self, index, **kwargs): """Move to a stored PTZ preset position.""" - logger.info('%s: goto PTZ preset %s', self.dev.dev_id, index) + self.log.info('goto PTZ preset %s', index) data = self._pack_ptz_dir_cmd(PtzDirection.PTZ_DIRECTION_PRE_TO, index) await self.send_command(BinaryCommands.CMD_PASSTHROUGH_STRING_PUT, data) async def ptz_set_preset(self, index, **kwargs): """Store the current position as a PTZ preset.""" - logger.info('%s: save PTZ preset %s', self.dev.dev_id, index) + self.log.info('save PTZ preset %s', index) data = self._pack_ptz_dir_cmd(PtzDirection.PTZ_DIRECTION_PRE_REC, index) await self.send_command(BinaryCommands.CMD_PASSTHROUGH_STRING_PUT, data) From 35cb355965bd2d3e8e70ea50571873e78aae40a4 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:14:06 +0300 Subject: [PATCH 35/61] Fix set_datetime timezone + decode datetime/wifi blocks (PTZA-confirmed) Real PTZA-156413 responses established the actual wire layouts: - DATETIME block: u32 UTC epoch + i32 timezone in seconds WEST of UTC (camera stored -7200 for UTC+2) + pad + char ntp_server[64]. set_datetime defaulted tz_seconds=0, which reset the camera to UTC+0 on every sync; it now defaults to the host's UTC offset (west-positive). - parse_dev_status rendered the tz with an inverted sign (UTC-2 for a UTC+2 camera); display is negated now. - New parse_datetime_block() and parse_wifi_settings() decode the DATETIME_GET (80 B) and WIFISETTING_GET (264 B: mode, security, ssid[32], password[128], five char[16] IP strings) responses. Verified against payload captures from the device. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/packets.py | 51 +++++++++++++++++++++++++++++++++++++++++++++- aiopppp/session.py | 11 +++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/aiopppp/packets.py b/aiopppp/packets.py index da7e440..a15a558 100644 --- a/aiopppp/packets.py +++ b/aiopppp/packets.py @@ -1,3 +1,4 @@ +import datetime import json import logging import struct @@ -150,7 +151,9 @@ def parse_dev_status(data): ) = struct.unpack('<4s5i64s10B6s4s4s3I', data[:124]) return { - 'tz': f"UTC{time_zone // 3600:+d}", #time zone is in seconds + # time_zone is in seconds WEST of UTC (UTC+2 is stored as -7200, + # confirmed on PTZA hardware), so negate it for display. + 'tz': f"UTC{-time_zone // 3600:+d}", 'uptime': sys_uptime, # Real Wi-Fi signal strength is not identified in this 124-byte struct; # don't masquerade the uptime as dBm (it produced bogus signal readings). @@ -179,6 +182,52 @@ def parse_dev_status(data): 'lamp': 0, # lamp is not in the status } + +def _cstr(b: bytes) -> str: + return b.split(b'\x00', 1)[0].decode('utf-8', errors='replace') + + +def parse_datetime_block(data): + """Decode a CMD_SYSTEM_DATETIME_GET response (layout confirmed on PTZA + hardware, len=80): u32 unix timestamp (UTC), i32 timezone as seconds WEST + of UTC (UTC+2 stored as -7200), 8 pad bytes, char ntp_server[64].""" + if len(data) < 8: + return {} + ts, tz_west = struct.unpack_from('= 80: + result['ntpServer'] = _cstr(data[16:80]) + return result + + +def parse_wifi_settings(data): + """Decode a CMD_NET_WIFISETTING_GET response (layout confirmed on PTZA + hardware, len=264): u32 mode, 12 pad bytes, u32 security, 4 pad bytes, + char ssid[32], char password[128], then five char[16] dotted-quad strings + (ip, netmask, gateway, dns1, dns2).""" + if len(data) < 184: + return {} + mode, = struct.unpack_from('= off + 16: + result[key] = _cstr(data[off:off + 16]) + return result + + class BinaryCmdPkt(DrwPkt): START_CMD = b'\x11\x0a' HEADER_FORMAT = '<2s3H' diff --git a/aiopppp/session.py b/aiopppp/session.py index bd808b9..279147e 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -999,11 +999,16 @@ async def set_alias(self, name): async def get_datetime(self, timeout=5): return await self._request(BinaryCommands.CMD_SYSTEM_DATETIME_GET, timeout=timeout) - async def set_datetime(self, when=None, tz_seconds=0): - """Set the device clock. Sends a unix timestamp plus timezone offset in - seconds. The exact wire layout is unverified against hardware.""" + async def set_datetime(self, when=None, tz_seconds=None): + """Set the device clock. Sends a unix timestamp (UTC epoch) plus the + timezone as seconds WEST of UTC -- the camera stores e.g. UTC+2 as + -7200 (layout confirmed against PTZA hardware via DATETIME_GET). + With tz_seconds=None the host's current UTC offset is used.""" if when is None: when = datetime.datetime.now() + if tz_seconds is None: + offset = datetime.datetime.now().astimezone().utcoffset() + tz_seconds = -int(offset.total_seconds()) if offset else 0 ts = int(when.timestamp()) payload = struct.pack(' Date: Sun, 23 Aug 2026 17:15:57 +0300 Subject: [PATCH 36/61] Web UI: decode info blocks, param-table read-back, raw as tooltip - /params now understands the PTZA response format: the camera ignores the requested id and returns the full table of params 1..12, so the value is read at (param_id - 1). Pair/scalar fallbacks kept. - Params readout shows only decoded values; the raw hex payload moved to a hover tooltip. - /info decodes datetime (parse_datetime_block) and wifi (parse_wifi_settings) instead of dumping hex; device_info shows the version field plus raw. - Simulator answers DATETIME_GET / WIFISETTING_GET / INF_GET and the 12-entry VIDEOPARAM table with PTZA-shaped payloads so all of the above is covered by the e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/http_server.py | 62 ++++++++++++++++++++++++++++++++---------- binary_camera.py | 30 +++++++++++++++++++- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/aiopppp/http_server.py b/aiopppp/http_server.py index 0698c18..7c91dd5 100644 --- a/aiopppp/http_server.py +++ b/aiopppp/http_server.py @@ -8,6 +8,7 @@ from aiohttp import web from .const import VideoParamType, VideoResolution, VideoRotate +from .packets import parse_datetime_block, parse_wifi_settings logger = logging.getLogger(__name__) @@ -93,14 +94,23 @@ def _camera_page_html(dev_id): try { const resp = await fetch(`/${DEV}/params`); const data = await resp.json(); - const parts = []; + el.textContent = ''; + let first = true; for (const [name, p] of Object.entries(data.params || {})) { - if (p.error) { parts.push(`${name}: <${p.error}>`); continue; } - parts.push(`${name}: ${p.symbol !== null ? p.symbol : p.value} (raw ${p.raw})`); - const sel = document.getElementById(`param-${name}`); - if (sel && p.symbol !== null) sel.value = p.symbol; + if (!first) el.append(' | '); + first = false; + const span = document.createElement('span'); + if (p.error) { + span.textContent = `${name}: <${p.error}>`; + } else { + span.textContent = `${name}: ${p.symbol !== null ? p.symbol : p.value}`; + span.title = `raw: ${p.raw}`; // hex payload on hover + const sel = document.getElementById(`param-${name}`); + if (sel && p.symbol !== null) sel.value = p.symbol; + } + el.append(span); } - el.textContent = parts.join(' | ') || 'no data'; + if (first) el.textContent = 'no data'; } catch (e) { el.textContent = `failed: ${e}`; } @@ -273,11 +283,18 @@ async def get_params(request): except Exception as e: result[name] = {'error': f'{type(e).__name__}: {e}'} continue - expected = VideoParamType[f'VIDEO_PARAM_TYPE_{name.upper()}'].value + param_id = VideoParamType[f'VIDEO_PARAM_TYPE_{name.upper()}'].value value = None - if len(payload) >= 8: + if len(payload) >= 48: + # PTZA-confirmed: the camera ignores the requested id and answers + # with the full table of params 1..12 (u32 each), so the value is + # looked up at (param_id - 1). + table = struct.unpack_from('<12I', payload) + if 1 <= param_id <= 12: + value = table[param_id - 1] + elif len(payload) >= 8: p, v = struct.unpack_from('= 4: value = struct.unpack_from('= 4 else {} + out['raw'] = data.hex(' ') + return out + + def decode_datetime(data): + return parse_datetime_block(data) or {'raw': data.hex(' ')} + + def decode_wifi(data): + return parse_wifi_settings(data) or {'raw': data.hex(' ')} + info = {} - for key, call in [ - ('status', session.get_status), + for key, call, decode in [ + ('status', session.get_status, None), # Short timeouts: cameras that don't implement a block shouldn't stall # the whole endpoint for the default 5 s each. ('device_info', functools.partial(session.get_device_info, timeout=3) - if hasattr(session, 'get_device_info') else None), + if hasattr(session, 'get_device_info') else None, decode_device_info), ('datetime', functools.partial(session.get_datetime, timeout=3) - if hasattr(session, 'get_datetime') else None), + if hasattr(session, 'get_datetime') else None, decode_datetime), ('wifi', functools.partial(session.get_wifi_settings, timeout=3) - if hasattr(session, 'get_wifi_settings') else None), + if hasattr(session, 'get_wifi_settings') else None, decode_wifi), ]: if call is None: continue @@ -319,7 +349,9 @@ async def get_info(request): except Exception as e: info[key] = f'error: {type(e).__name__}: {e}' continue - info[key] = value.hex(' ') if isinstance(value, bytes) else value + if isinstance(value, bytes): + value = decode(value) if decode else value.hex(' ') + info[key] = value return web.json_response({'status': 'ok', 'info': info}) diff --git a/binary_camera.py b/binary_camera.py index 3f7b0c3..835b0cb 100644 --- a/binary_camera.py +++ b/binary_camera.py @@ -151,7 +151,35 @@ async def process_drw(self, data): elif cmd_id == BinaryCommands.CMD_PEER_VIDEOPARAM_SET: self._send_cmd_ack(BinaryCommands.ACK_PEER_VIDEOPARAM_SET) elif cmd_id == BinaryCommands.CMD_PEER_VIDEOPARAM_GET: - self._send_cmd_ack(BinaryCommands.ACK_PEER_VIDEOPARAM_GET, struct.pack(' HD + table[8] = 1 # ircut on + self._send_cmd_ack(BinaryCommands.ACK_PEER_VIDEOPARAM_GET, struct.pack('<12I', *table)) + elif cmd_id == BinaryCommands.CMD_SYSTEM_DATETIME_GET: + # PTZA layout: u32 UTC epoch, i32 tz seconds west, pad, ntp[64] + self._send_cmd_ack( + BinaryCommands.ACK_SYSTEM_DATETIME_GET, + struct.pack('', data.hex(' ')) + self._send_cmd_ack(BinaryCommands.ACK_SYSTEM_DATETIME_SET) + elif cmd_id == BinaryCommands.CMD_NET_WIFISETTING_GET: + # PTZA layout: mode, pad12, security, pad4, ssid[32], + # password[128], five char[16] dotted-quad strings + wifi = struct.pack( + ' Date: Sun, 23 Aug 2026 17:29:56 +0300 Subject: [PATCH 37/61] Don't let late pre-wrap video chunks re-arm the epoch detector Root cause of the FTYC no-video bug (BUG-002): the camera's video DRW index happened to wrap 0xffff->0 shortly after stream start, and chunks arrive out of order around the wrap. A pre-wrap chunk (e.g. 65529) retransmitted after the wrap was correctly assigned the previous epoch by _get_drw_epoch, but still advanced last_drw_pkt_idx to 65529 -- so the next post-wrap chunk (<0x100) looked like ANOTHER wrap and bumped the epoch again. debug.log shows the epoch ping-ponging 0->6 within seconds, scattering reassembly indices 0x10000 apart; only the pre-wrap first frame ever displayed. last_drw_pkt_idx now only advances for chunks of the current epoch. Regression-tested with the exact out-of-order wrap sequence captured from FTYC-577508: epoch stays at 1, late chunks map into epoch 0, absolute indices come out contiguous. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index 279147e..c33f434 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -346,7 +346,15 @@ async def handle_drw(self, drw_pkt): self.log.info('Video epoch changed %s -> %s', self.video_epoch, pkt_epoch) self.video_epoch = pkt_epoch self.last_drw_pkt_idx = drw_pkt._cmd_idx - elif self.last_drw_pkt_idx < drw_pkt._cmd_idx: + elif pkt_epoch == self.video_epoch and self.last_drw_pkt_idx < drw_pkt._cmd_idx: + # Only chunks from the CURRENT epoch may advance the high-water + # mark. A late pre-wrap chunk (e.g. idx 65529 retransmitted + # after the counter wrapped to 0) belongs to the previous epoch; + # feeding it in here re-armed the wrap detector and the next + # post-wrap chunk bumped the epoch again -- ping-ponging the + # epoch up several times a second (seen on FTYC hardware, + # epochs 0->6 in seconds) and scattering reassembly indices + # 0x10000 apart, which killed every frame after the first. self.last_drw_pkt_idx = drw_pkt._cmd_idx if self.video_stale_at: From e973bfa99bf36faf45f0288960929ed81d4e183e Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:30:56 +0300 Subject: [PATCH 38/61] Web UI: snapshot falls back to the latest video frame CMD_SNAPSHOT_GET goes unanswered on all tested hardware (FTYC and PTZA alike) -- the snapshot button always failed with 'camera returned no snapshot'. The route now tries the command briefly (3 s) and then serves the most recent reassembled MJPEG frame from the session's frame buffer; the x-snapshot-source response header says which path produced the image. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/http_server.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/aiopppp/http_server.py b/aiopppp/http_server.py index 7c91dd5..df94498 100644 --- a/aiopppp/http_server.py +++ b/aiopppp/http_server.py @@ -356,21 +356,31 @@ def decode_wifi(data): async def get_snapshot(request): - """Still image (ENH-002).""" + """Still image (ENH-002). CMD_SNAPSHOT_GET goes unanswered on all tested + hardware (FTYC + PTZA), so fall back to the latest reassembled video + frame; the x-snapshot-source header says which path served the image.""" session, err = _get_session(request) if err: return err - if not hasattr(session, 'get_snapshot'): - return _json_error('not supported by this device', 501) - try: - data = await session.get_snapshot() - except Exception as e: - return _json_error(f'{type(e).__name__}: {e}', 500) + + data, source = b'', 'camera' + if hasattr(session, 'get_snapshot'): + try: + data = await session.get_snapshot(timeout=3) + except Exception: + data = b'' + if not data: + frame = getattr(session.frame_buffer, 'latest_frame', None) + if frame is not None: + data, source = frame.data, 'video-frame' if not data: - return _json_error('camera returned no snapshot', 504) + return _json_error( + 'camera did not answer SNAPSHOT_GET and no video frame is buffered' + ' -- start the video stream once and retry', 504) return web.Response(body=data, headers={ 'content-type': 'image/jpeg', 'cache-control': 'no-store', + 'x-snapshot-source': source, }) From 62e0aa44b301bf469e3e80104d7bfd4e39606893 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:49:19 +0300 Subject: [PATCH 39/61] Never publish empty video frames (fixes FTYC one-frame freeze) FTYC firmware frames the stream as [bare 0x20-header packet] [header+data packet][data...] -- two adjacent header chunks per frame (confirmed by the DRW size histogram: ~350 len-36 bare-header packets matching ~330 len-996 header+data packets). The zero-length window between the two headers reassembled to an empty frame, and publishing it wrote a Content-Length: 0 part into the MJPEG stream after every real frame -- browsers froze on the first image. PTZA cameras don't send the bare header, which is why only FTYC broke. - process_video_frame: skip publishing empty frames (real frames now come out byte-exact -- regression-tested with the FTYC framing pattern). - stream_video: defensively skip empty frames. - New diagnostics: sample the first 8 stream headers (+ every 5000th) at INFO, and log published frame head bytes at DEBUG flagging NOT-JPEG. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/http_server.py | 2 ++ aiopppp/session.py | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/aiopppp/http_server.py b/aiopppp/http_server.py index df94498..db9ce48 100644 --- a/aiopppp/http_server.py +++ b/aiopppp/http_server.py @@ -445,6 +445,8 @@ async def stream_video(request): while True: frame = await frame_buffer.get() + if not frame.data: + continue header = f'--{boundary}\r\n'.encode() header += b'Content-Length: %d\r\n' % len(frame.data) header += b'Content-Type: image/jpeg\r\n\r\n' diff --git a/aiopppp/session.py b/aiopppp/session.py index c33f434..cad6d7a 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -109,6 +109,11 @@ def __init__(self, *args, **kwargs): # the camera is streaming video we can't frame (e.g. a different marker # than VIDEO_MARKER) -- log a sample so the format can be identified. self._chunks_since_boundary = 0 + # Header diagnostics: some firmwares (FTYC) put the 0x20-byte stream + # header on far more chunks than one per frame. Sample a few headers so + # the type/length fields can be identified from a plain log. + self._boundary_headers_logged = 0 + self._boundaries_seen = 0 async def process_video_queue(self): while True: @@ -127,6 +132,12 @@ async def handle_incoming_video_packet(self, pkt_epoch, pkt): # 0x20 - size of the header starting with this magic if video_payload.startswith(VIDEO_MARKER): self._chunks_since_boundary = 0 + self._boundaries_seen += 1 + if self._boundary_headers_logged < 8 or self._boundaries_seen % 5000 == 0: + self._boundary_headers_logged += 1 + self.log.info('stream header sample #%d (payload len=%d): [%s]', + self._boundaries_seen, len(video_payload), + video_payload[:0x20].hex(' ')) self.video_boundaries.add(video_chunk_idx) self.video_received[video_chunk_idx] = video_payload[0x20:] else: @@ -166,7 +177,20 @@ async def process_video_frame(self, new_idx=None): if index != self.last_video_frame and not self._frame_missing: self.last_video_frame = index data = b''.join(self.video_received[i] for i in range(index, last_index)) - await self.frame_buffer.publish(VideoFrame(idx=index, data=data)) + # A reassembled MJPEG frame must be SOI..EOI; log rejects so a + # polluted stream (e.g. muxed sub-streams) is visible in the log. + if self.log.isEnabledFor(logging.DEBUG): + valid = data[:2] == b'\xff\xd8' + self.log.debug('publish frame idx=%s len=%d head=[%s]%s', + index, len(data), data[:4].hex(' '), + '' if valid else ' NOT-JPEG') + if data: + # FTYC frames a stream as [bare 0x20-header pkt][header+data + # pkt][data...]: the two adjacent header chunks make a + # zero-length "frame" between them. Publishing it emitted a + # Content-Length: 0 MJPEG part after every real frame, which + # froze browsers on the first image. + await self.frame_buffer.publish(VideoFrame(idx=index, data=data)) if self.log.isEnabledFor(logging.DEBUG): completeness = ''.join( From d8d58069db94ea229dc218724b8a44d6f7e14d0c Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:50:08 +0300 Subject: [PATCH 40/61] set_datetime: send the full 80-byte struct, preserving the NTP server FTYC firmware applies the timestamp but ignores the timezone when the SET carries only the 8-byte (ts, tz) prefix. Mirror the confirmed DATETIME_GET layout instead -- u32 UTC epoch, i32 seconds west of UTC, 8 pad bytes, char ntp_server[64] -- reading the camera's current NTP server first so it isn't clobbered (default time.windows.com when the camera doesn't answer). Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/aiopppp/session.py b/aiopppp/session.py index cad6d7a..7160d46 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -1031,18 +1031,31 @@ async def set_alias(self, name): async def get_datetime(self, timeout=5): return await self._request(BinaryCommands.CMD_SYSTEM_DATETIME_GET, timeout=timeout) - async def set_datetime(self, when=None, tz_seconds=None): - """Set the device clock. Sends a unix timestamp (UTC epoch) plus the - timezone as seconds WEST of UTC -- the camera stores e.g. UTC+2 as - -7200 (layout confirmed against PTZA hardware via DATETIME_GET). - With tz_seconds=None the host's current UTC offset is used.""" + async def set_datetime(self, when=None, tz_seconds=None, ntp_server=None): + """Set the device clock. Sends the full 80-byte datetime struct + mirroring DATETIME_GET (confirmed on PTZA hardware): u32 unix + timestamp (UTC epoch), i32 timezone as seconds WEST of UTC (UTC+2 is + stored as -7200), 8 pad bytes, char ntp_server[64]. Some firmwares + (FTYC) apply the timestamp but not the timezone when sent only the + 8-byte prefix, hence the full struct. With tz_seconds=None the host's + current UTC offset is used; with ntp_server=None the camera's current + NTP server is preserved (read back via DATETIME_GET, defaulting to + time.windows.com if it doesn't answer).""" if when is None: when = datetime.datetime.now() if tz_seconds is None: offset = datetime.datetime.now().astimezone().utcoffset() tz_seconds = -int(offset.total_seconds()) if offset else 0 + if ntp_server is None: + ntp_server = 'time.windows.com' + try: + current = await self.get_datetime(timeout=2) + if len(current) >= 80: + ntp_server = current[16:80].split(b'\x00', 1)[0].decode('ascii') or ntp_server + except Exception: + self.log.debug('DATETIME_GET before set failed; using default NTP server') ts = int(when.timestamp()) - payload = struct.pack(' Date: Sun, 23 Aug 2026 18:17:05 +0300 Subject: [PATCH 41/61] Route FTYC's muxed audio off the video channel (fixes one-frame freeze) Live capture from FTYC-577508 settled the frame format: the camera muxes audio INTO the video DRW channel, one packet per video frame, using the same 0x20-byte 55aa15a8 header distinguished only by the stream-type byte at payload offset 4 (0x03 = JPEG header packet, 0x06 = audio; matches cam-reverse). Treating the audio packets as frame boundaries corrupted every reassembled frame with ~960 bytes of G.711 and produced the empty boundary-to-boundary windows behind the one-frame freeze. Audio-typed packets are now excluded from frame reassembly (their index slot is kept, empty, so the surrounding frame's window still completes) and handed to the audio pipeline instead -- FTYC live audio now flows without LIVEAUDIO_START. Verified against the real camera: 48/48 valid distinct JPEG frames in 6 s (~8 fps), zero empties, 47 audio frames decoded; plus an offline regression replaying the captured framing pattern byte-exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/session.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/aiopppp/session.py b/aiopppp/session.py index 7160d46..ba466e2 100644 --- a/aiopppp/session.py +++ b/aiopppp/session.py @@ -41,6 +41,11 @@ # Prefix of the 0x20-byte header that marks the first chunk of a video frame. VIDEO_MARKER = b'\x55\xaa\x15\xa8' +# Byte 4 of the 0x20 header is the stream type (captured from FTYC hardware; +# matches cam-reverse). FTYC muxes audio onto the VIDEO DRW channel: each video +# frame is preceded by one audio packet with the same magic but type 0x06. +STREAM_TYPE_JPEG = 0x03 +STREAM_TYPE_AUDIO = 0x06 class SessionLogAdapter(logging.LoggerAdapter): @@ -138,6 +143,15 @@ async def handle_incoming_video_packet(self, pkt_epoch, pkt): self.log.info('stream header sample #%d (payload len=%d): [%s]', self._boundaries_seen, len(video_payload), video_payload[:0x20].hex(' ')) + stream_type = video_payload[4] if len(video_payload) > 4 else None + if stream_type == STREAM_TYPE_AUDIO: + # Muxed audio (FTYC): not a frame boundary. Occupy the index + # with an empty chunk so the surrounding video frame's window + # still completes, and hand the packet to the audio pipeline. + self.video_received[video_chunk_idx] = b'' + await self.handle_incoming_audio_packet(pkt) + await self.process_video_frame(video_chunk_idx) + return self.video_boundaries.add(video_chunk_idx) self.video_received[video_chunk_idx] = video_payload[0x20:] else: From b6980f2959c7a4e491092455a1ae5f6d63480f15 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:17:58 +0300 Subject: [PATCH 42/61] parse_datetime_block: handle both firmware layouts (PTZA vs FTYC) Live probing of FTYC-577508 showed its DATETIME block has no timezone field: the u32 at offset 4 is a constant (0xE0), and the timestamp already renders as local time -- the camera adds its own stored offset when the clock is set with a UTC epoch (confirmed by SET/GET deltas: sent ts comes back +offset). Decoding it with the PTZA layout displayed nonsense (UTC-1 / UTC+0), which looked like 'sync doesn't set the tz'. The two layouts are told apart by tz plausibility (multiple of 15 min, within +/-14 h). PTZA blocks decode as before plus a rendered local time; FTYC blocks report local time and tz 'device-managed'. Co-Authored-By: Claude Opus 4.8 (1M context) --- aiopppp/packets.py | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/aiopppp/packets.py b/aiopppp/packets.py index a15a558..1abcd4d 100644 --- a/aiopppp/packets.py +++ b/aiopppp/packets.py @@ -187,20 +187,44 @@ def _cstr(b: bytes) -> str: return b.split(b'\x00', 1)[0].decode('utf-8', errors='replace') +def _render_ts(ts): + return datetime.datetime.utcfromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') + + def parse_datetime_block(data): - """Decode a CMD_SYSTEM_DATETIME_GET response (layout confirmed on PTZA - hardware, len=80): u32 unix timestamp (UTC), i32 timezone as seconds WEST - of UTC (UTC+2 stored as -7200), 8 pad bytes, char ntp_server[64].""" + """Decode a CMD_SYSTEM_DATETIME_GET response (80 bytes, two firmware + variants confirmed on hardware): + + - PTZA: u32 unix timestamp (UTC), i32 timezone as seconds WEST of UTC + (UTC+2 stored as -7200), 8 pad bytes, char ntp_server[64]. + - FTYC: u32 timestamp that already renders as LOCAL time (the camera adds + its own internally-stored offset when the clock is set), then constant + non-tz fields, ntp_server at the same offset. There is no tz in the + block, so the field-4 value (e.g. 0xE0) must not be shown as one. + + The variants are told apart by tz plausibility: a real timezone is a + multiple of 15 minutes within +/-14 h.""" if len(data) < 8: return {} - ts, tz_west = struct.unpack_from('= 80: result['ntpServer'] = _cstr(data[16:80]) return result From a7d1004d7412a9de1070d77748f27e3e0988e1ae Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:33:27 +0300 Subject: [PATCH 43/61] Web UI: low-latency audio player + honest rotate read-back - Audio: new 'Play (low-latency)' button streams the WAV endpoint via fetch and schedules raw PCM chunks through Web Audio (~100 ms behind live). The