-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathConnectionLayer.py
More file actions
1976 lines (1623 loc) · 78 KB
/
ConnectionLayer.py
File metadata and controls
1976 lines (1623 loc) · 78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
baichuan.py
===========
:author: Aidan A. Bradley
:date: April 23rd, 2026
The Baichuan protocol is a proprietary communication and authorization protocol
developed by Baichuan SoC Company for their ARM-based camera systems. The main
protocol has not been publicly discussed or disseminated in any form by ReoLink
or Baichuan (the company).
This protocol was initially reverse-engineered by George Hilliard
(a.k.a. *thirtythreeforty* on GitHub), and then extensively polished into a
full-fledged program known as **NeoLink**. The project's current maintainer is
Andrew W. King (a.k.a. *quantumentangledandy* on GitHub). Although the project
has grown dusty, this Python system is intended as a complete rewrite of the
NeoLink codebase — not to mimic or recreate it, but to build a base library
with extensive commentary.
The exact mechanisms implemented here were determined through extensive testing
and by reading disparate documentation from the original authors and the
community surrounding NeoLink, as well as some code evaluated inside the
``reolink_aio`` Python module. Although ``reolink_aio`` exists and works, this
module aims to be more focused on building a dedicated streaming library for
ReoLink cameras, much in the way NeoLink was originally aiming to.
**Background / Motivation**
The impetus for this module came from repeated failed attempts to use the
built-in RTSP/RTMP streams. Artifacting, image columnation, I-frame issues,
improper buffering, color shifts, and other anomalies made those streams
unusable for a live outdoor activity feed. Switching to NeoLink yielded a
dramatic quality increase at low CPU/GPU cost. However, NeoLink struggled with
multiple concurrent high-resolution streams — even on a machine with an
RTX 4070, a 4.5 GHz AMD CPU, and 32 GB of RAM. Increasing I-frame intervals
(x2, x4) and raising resolution/fps caused a buffer runaway, with RAM
allocation creeping up rapidly. Partitioning cameras across multiple NeoLink
instances and coupling MediaMTX helped stabilize things somewhat, but the
added chain and complexity reduced overall reliability — particularly when
approaching the limits of older Proliant Gen7/8 server NICs used for ingest.
Switching to direct desktop ingest showed no improvement; NeoLink's buffer
management was the clear bottleneck.
None of this diminishes NeoLink's achievement. It is an incredible piece of
work, and the community around it provided the examples and insight needed to
understand and reason about Baichuan stream chains. This module stands on that
foundation.
**Verified handshake sequence** (confirmed against Duo 3 PoE firmware)
Stage 1 — GetNonce
Client sends: legacy header (0x6514, 20 bytes) + GetNonce XML payload.
Camera sends: legacy header (0x6614, 20 bytes) + BC-encrypted Encryption
XML. The Encryption XML contains ``<type>`` (login encryption method) and
``<nonce>`` (the session nonce string). The ``<type>`` field drives login
path selection in Stage 2.
Stage 2 — Login (BC XOR path, ``<type>md5``)
Client sends: modern header (0x6414, 24 bytes) + BC-encrypted LoginUser XML.
Credentials: MD5(value + nonce)[:31].upper() — 31 hex chars, not 32.
Camera sends: modern header (0x0000, 24 bytes, status=200) + BC-encrypted
DeviceInfo and StreamInfoList XML.
Stage 2 — Login (AES path, ``<type>aes``)
Client sends: modern header (0x6414, 24 bytes) + AES-128-CFB-encrypted
LoginUser XML. Credentials are plain MD5(value).hexdigest() — no nonce
mixing, lowercase, all 32 chars. AES key: MD5(nonce + "-" + password)[:16].
Camera sends: same response structure as BC path.
.. warning::
The AES login path is implemented from the community protocol
specification but has not yet been verified empirically against
RLC-810A or RLC-1212A hardware. It will be validated and corrected
before the Session layer is built on top of this module.
Stage 3 — Stream
Client sends: modern header (cmd_id=3) + BC-encrypted Preview XML.
Camera sends: one of three acknowledgement forms depending on firmware:
* **XML ack** (older firmware): ``<Extension>`` block with
``<binaryData>1</binaryData>``, then binary stream frames begin.
* **Bare 200 OK** (Duo 3 PoE, confirmed): class-0x0000 modern header,
status=200, zero-length payload; binary stream frames begin immediately.
* **Implicit ack** (some variants): a non-XML payload on a 200 response;
the first media frame arrives as the ack response body and has already
been consumed from the socket before ``request_stream()`` returns.
**Key implementation notes**
- The login response uses message class 0x0000 (a 24-byte modern header).
Any ``HEADER_LENGTHS`` table that omits 0x0000 will misread the response,
prepending 4 rogue payload-offset bytes to the payload and producing garbled
output. This was the final bug resolved before a working login was achieved.
- BC credential hashes are MD5(value + nonce) truncated to 31 hex characters
(uppercase). The protocol allocates a 32-byte field with a null terminator;
byte 32 is always zero and is never compared by the camera firmware.
- AES credential hashes are plain MD5(value).hexdigest() — no nonce mixing,
lowercase, all 32 characters. The nonce appears only in AES key derivation,
not in the credential fields themselves.
- BC encryption uses CH_ID (0xFA for host-level commands) as the enc_offset
for all outgoing payloads. The camera echoes this byte at header[12] in
its responses, which is the correct offset to pass to ``bc_crypt()`` for
decryption.
- All multi-byte integers on the wire are little-endian, consistent with the
ARM architecture of the underlying Baichuan SoC.
- The encryption advertisement byte in the GetNonce header must be 0x12
(i.e. ``b'\\x12\\xdc'``). Other values cause the camera to silently discard
the message with no response. The meaning of 0x12 is not documented in any
public specification; it is an empirically required magic value.
**Camera model heterogeneity**
The production deployment covers five distinct Reolink models. Two fields
from ``DeviceInfo`` are critical for geometry-correct decoding at the layers
above the wire:
* ``bino_type`` — non-zero for dual-sensor (Duo series) cameras. The Session
layer must propagate this to the Stream Pipe layer so consumers receive
correct panoramic geometry metadata.
* ``need_rotate`` — ``1`` on Duo 3 PoE (confirmed), indicating the encoded
frame data is rotated 90° relative to the declared display dimensions.
Downstream decoders that ignore this produce the columnation artefacts
observed in naive RTSP pipelines.
+----------------+---------+-------------------------------------------+
| Model | Count | Notes |
+================+=========+===========================================+
| RLC-810A | 4 | Single sensor, 8 MP |
| RLC-1212A | 3 | Single sensor, 12 MP |
| Duo 2 PoE | 1 | Dual sensor panoramic |
| Duo 2V PoE | 3 | Dual sensor, vertical orientation |
| Duo 3 PoE | 4 | Dual sensor, needRotate=1 (confirmed) |
+----------------+---------+-------------------------------------------+
**Module structure**
This module is the complete Serial Layer (wire communication layer). It is
organised into ten sections:
1. Protocol constants — wire values, command IDs, cipher constants
2. BC cipher — ``bc_crypt`` (symmetric XOR, str/bytes input,
validated offset)
3. AES cipher — ``derive_aes_key``, ``aes_encrypt``,
``aes_decrypt``
4. Header construction — ``build_header``
5. Socket I/O — ``recv_exact``, ``recv_frame``, ``parse_header``
6. Credential helpers — ``hash_credential`` (BC XOR path),
``hash_credential_plain`` (AES path)
7. Payload builders — ``build_get_nonce_payload``,
``build_login_payload``,
``build_aes_login_payload``,
``build_preview_payload``
8. Data model — ``DeviceInfo``, ``EncodeTable``, ``StreamInfo``,
``LoginResponse``, ``Session``
9. Session lifecycle — ``open_session``, ``get_nonce``, ``login``,
``request_stream``, ``close_session``
10. High-level API — ``BaichuanSession`` context manager with
``connect()`` for single-call authentication
"""
import hashlib
import logging
import socket
import struct
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Optional, Union
# AES is required for cameras that negotiate the AES login path (enc_type "aes").
# PyCryptodome is the maintained fork; pycrypto is the legacy fallback.
# Install with: pip install pycryptodome
# BC XOR login (the path verified on all currently tested cameras) is not affected
# if this import fails; the ImportError surfaces only when aes_encrypt/aes_decrypt
# are actually called.
try:
from Cryptodome.Cipher import AES as _AES
except ImportError:
try:
from Crypto.Cipher import AES as _AES # type: ignore[no-redef]
except ImportError:
_AES = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ============================================================================
# 1. PROTOCOL CONSTANTS
# ============================================================================
#
# All integer constants are written in standard (big-endian-readable) form,
# e.g. 0x6514 rather than 0x1465. When building wire messages they are
# explicitly serialised as little-endian, so callers never have to reason
# about byte order until the moment a header is assembled.
# ============================================================================
MAGIC: int = 0x0ABCDEF0
"""
Magic number that opens every Baichuan frame.
Serialised as little-endian it becomes the byte sequence ``f0 de bc 0a`` on
the wire. A separate magic value (``a0 cd ed 0f``) exists for NVR-to-IPC
internal traffic and is not relevant for client implementations. This value
is fixed across all known ReoLink/Baichuan devices.
"""
PORT: int = 9000
"""
Default TCP port for the Baichuan protocol.
ReoLink cameras listen on port 9000 out of the box. This can be changed from
the camera's network settings, in which case pass the custom port explicitly
wherever a port argument is accepted.
"""
RECV_TIMEOUT: float = 15.0
"""
Socket receive timeout in seconds.
How long the library will block waiting for data before raising a timeout
error. Increase on high-latency or heavily loaded networks; decrease for
faster failure detection in managed reconnect loops at the Session layer.
"""
XML_KEY: bytes = bytes([0x1F, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0xFF])
"""
8-byte rotating XOR key used to obfuscate XML payloads (BC encryption).
**Do not modify.** This key is identical across every known ReoLink device;
changing it will break decryption of all camera responses.
"""
AES_IV: bytes = b"0123456789abcdef"
"""
Fixed 16-byte AES initialisation vector used for the AES-128-CFB login path.
Hardcoded by the protocol specification. The IV does not change between
sessions; only the derived AES key changes (incorporating the session nonce).
"""
HEADER_LENGTHS: dict[int, int] = {
0x6514: 20, # Legacy — client GetNonce request
0x6614: 20, # Legacy — camera nonce response (no payload-offset field)
0x6414: 24, # Modern — client login / command (includes payload-offset)
0x0000: 24, # Modern — camera login response (status at offset 16)
0x6482: 24, # Modern — file download variant
0x0146: 24, # Modern — alternate command variant
}
"""
Mapping of message-class integers to their corresponding header sizes in bytes.
Baichuan headers come in two sizes:
* **20 bytes** — legacy classes (nonce exchange, older firmware). Layout::
MAGIC(4) + cmd_id(4) + msg_len(4) + mess_id(4) + encrypt(2) + class(2)
* **24 bytes** — modern classes (login, commands, camera responses). Same
layout plus::
… + payload_offset(4)
The ``encrypt`` field in legacy client headers and the ``status`` field in
modern camera response headers occupy the same two bytes at offset 16 —
they serve different roles depending on message direction and class.
.. warning::
``0x0000`` **must** be present in this table. Without it, ``recv_frame``
reads only 20 bytes for the login response header. The 4-byte
payload-offset field is then prepended to the payload, producing garbled
XML. This was the final bug resolved before a successful login was
achieved.
Keys are message-class integers in standard (big-endian-readable) notation.
"""
# Baichuan command IDs — placed in the cmd_id field (offset 4) of every header.
CMD_LOGIN: int = 1
"""Command ID for GetNonce requests and Login messages."""
CMD_LOGOUT: int = 2
"""Command ID for the session logout message."""
CMD_VIDEO: int = 3
"""Command ID for Preview (stream start) requests."""
# Encryption advertisement bytes for the GetNonce legacy (20-byte) header.
# 0x12 at offset 16 is an empirically required magic value. Other values
# (0x01, 0x02, 0x03) cause the camera to silently discard the frame.
ENCRYPT_ADV: bytes = b'\x12\xdc'
"""
Encryption advertisement bytes for the GetNonce legacy (20-byte) header.
Placed at offset 16 (the ``encrypt`` field). ``0x12`` is an empirically
required magic value whose meaning is undocumented in any public specification.
``0xdc`` is the "unknown" second byte observed in community implementations.
Do not change without testing against physical hardware.
"""
CH_ID_HOST: int = 0xFA
"""
Host-level channel identifier.
Placed as the first byte of the 4-byte ``mess_id`` field (offset 12) in every
outgoing header. Also used as the ``enc_offset`` parameter when BC-encrypting
outgoing payloads, because the camera reads this same byte from the received
header to determine the decryption offset for its responses.
"""
# Encryption type strings returned in the camera's <type> element (GetNonce
# response). These drive login path selection in login().
ENC_TYPE_MD5: str = "md5"
"""
Nonce response encryption type indicating the BC XOR login path.
When the camera's ``<Encryption>`` response contains ``<type>md5</type>``,
login credentials are hashed as MD5(value + nonce)[:31].upper() and the
payload is BC XOR encrypted. This is the path verified against all currently
tested cameras (Duo 3 PoE, confirmed working).
"""
ENC_TYPE_AES: str = "aes"
"""
Nonce response encryption type indicating the AES-128-CFB login path.
When the camera returns ``<type>aes</type>``, credentials are plain
MD5(value).hexdigest() (no nonce mixing), and the LoginUser payload is
AES-128-CFB encrypted with a nonce-derived key.
.. warning::
This path is implemented from the community protocol specification but has
not been verified against RLC-810A or RLC-1212A hardware. The ``authMode``
field in ``DeviceInfo`` (expected non-zero for AES cameras) will be used
for post-login verification once testing is possible.
"""
# ============================================================================
# 2. BC CIPHER
# ============================================================================
def bc_crypt(enc_offset: int, data: Union[str, bytes]) -> bytes:
"""
Apply the Baichuan XOR cipher to *data*.
The BC cipher is symmetric — the same function handles both encryption
of outgoing payloads and decryption of incoming payloads.
**Algorithm:**
For each byte at index *i* in *data*:
.. code-block:: text
key_byte = XML_KEY[(enc_offset + i) % 8]
result[i] = data[i] ^ key_byte ^ (enc_offset & 0xFF)
where ``offset_byte = enc_offset & 0xFF``.
**Direction guide:**
* **Outgoing** (client → camera): pass ``session.ch_id`` as *enc_offset*.
The camera reads the ch_id byte from the received header to derive the
same offset for decryption.
* **Incoming** (camera → client): pass ``header[12]`` (the first byte of
the camera's ``mess_id`` field) as *enc_offset*.
Args:
enc_offset: Byte-range offset in [0, 255]. Used as both the
key-rotation seed and the per-byte XOR mask. Values outside this
range raise ``ValueError``; the protocol provides only one byte for
this field and silent masking would hide bugs.
data: Raw bytes or UTF-8 string to transform. Strings are encoded to
UTF-8 before processing.
Returns:
Transformed bytes of the same length as the input.
Raises:
ValueError: If *enc_offset* is outside [0, 255].
TypeError: If *data* is neither ``str`` nor ``bytes``/``bytearray``.
"""
if not (0 <= enc_offset <= 255):
raise ValueError(
f"BC cipher enc_offset must be in [0, 255], got {enc_offset}."
)
if isinstance(data, str):
data = data.encode("utf-8")
if not isinstance(data, (bytes, bytearray)):
raise TypeError(
f"bc_crypt expects str or bytes, got {type(data).__name__}."
)
offset_byte = enc_offset & 0xFF
result = bytearray(len(data))
for i, byte in enumerate(data):
key_byte = XML_KEY[(enc_offset + i) % 8]
result[i] = byte ^ key_byte ^ offset_byte
return bytes(result)
# ============================================================================
# 3. AES CIPHER
# ============================================================================
def derive_aes_key(nonce: str, password: str) -> bytes:
"""
Derive the 16-byte AES-128 key for the AES login path.
Key derivation formula (from community protocol specification)::
key_material = nonce + "-" + password
key = MD5(key_material).hexdigest()[:16].encode("ascii")
The hyphen separator is literal and required. The result is the first
16 characters of the hex digest encoded as ASCII bytes, yielding a
128-bit (16-byte) key.
Args:
nonce: Session nonce string from the GetNonce response.
password: Plain-text camera password.
Returns:
16-byte AES key.
Example:
If ``nonce = "abc123"`` and ``password = "secret"``::
key_material = "abc123-secret"
key = MD5("abc123-secret").hexdigest()[:16].encode("ascii")
"""
key_material = f"{nonce}-{password}"
return hashlib.md5(key_material.encode("utf-8")).hexdigest()[:16].encode("ascii")
def aes_encrypt(key: bytes, plaintext: Union[str, bytes]) -> bytes:
"""
Encrypt *plaintext* with AES-128-CFB using the fixed protocol IV.
The Baichuan AES path uses AES-128 in CFB mode with 128-bit feedback
(CFB128) and the fixed IV ``AES_IV`` (``b"0123456789abcdef"``). The key
must be derived via ``derive_aes_key()`` for each session.
Args:
key: 16-byte AES key from ``derive_aes_key()``.
plaintext: Data to encrypt. Strings are UTF-8 encoded before
encryption.
Returns:
AES-128-CFB128 ciphertext bytes of the same length as the input.
Raises:
RuntimeError: If pycryptodome (or pycrypto) is not installed.
Install with: ``pip install pycryptodome``
"""
if _AES is None:
raise RuntimeError(
"AES login path requires pycryptodome. "
"Install with: pip install pycryptodome"
)
if isinstance(plaintext, str):
plaintext = plaintext.encode("utf-8")
cipher = _AES.new(key, _AES.MODE_CFB, iv=AES_IV, segment_size=128)
return cipher.encrypt(plaintext)
def aes_decrypt(key: bytes, ciphertext: bytes) -> bytes:
"""
Decrypt *ciphertext* with AES-128-CFB using the fixed protocol IV.
Inverse of ``aes_encrypt()``. Uses the same key, IV, and mode. Camera
responses under the AES path are decrypted with this function using the
same derived key that encrypted the login payload.
Args:
key: 16-byte AES key from ``derive_aes_key()``.
ciphertext: Encrypted bytes from the camera.
Returns:
Decrypted plaintext bytes.
Raises:
RuntimeError: If pycryptodome (or pycrypto) is not installed.
"""
if _AES is None:
raise RuntimeError(
"AES login path requires pycryptodome. "
"Install with: pip install pycryptodome"
)
cipher = _AES.new(key, _AES.MODE_CFB, iv=AES_IV, segment_size=128)
return cipher.decrypt(ciphertext)
# ============================================================================
# 4. HEADER CONSTRUCTION
# ============================================================================
def build_header(
cmd_id: int,
payload_len: int,
message_class: int,
ch_id: int = CH_ID_HOST,
mess_id: int = 0,
encrypt: bytes = ENCRYPT_ADV,
status: int = 0,
payload_offset: int = 0,
) -> bytes:
"""
Build a Baichuan message header.
The header size (20 or 24 bytes) is determined automatically from
*message_class* using ``HEADER_LENGTHS``.
**Legacy layout** (20 bytes, classes 0x6514 / 0x6614)::
Offset Bytes Field
0 4 Magic (LE: f0 de bc 0a)
4 4 cmd_id (LE uint32)
8 4 payload_len (LE uint32)
12 4 mess_id ([ch_id:1][mess_id:3], LE)
16 2 encrypt (capability advertisement)
18 2 message_class (LE uint16)
**Modern layout** (24 bytes, classes 0x6414 / 0x0000 / etc.)::
Offset Bytes Field
0–17 (same as legacy)
16 2 status (0x0000 in all client requests)
18 2 message_class (LE uint16)
20 4 payload_offset (LE uint32)
Note on offsets 16–17: these bytes hold ``encrypt`` in legacy headers and
``status`` in modern headers. The *encrypt* argument is unused for modern
classes; the *status* argument is unused for legacy classes.
Args:
cmd_id: Baichuan function code (``CMD_LOGIN``, etc.).
payload_len: Byte length of the payload that follows this header.
message_class: Selects the header layout and total byte size.
ch_id: Channel identifier byte. ``CH_ID_HOST`` (0xFA) for
all single-camera host-level commands. Also used as
the BC ``enc_offset`` for the accompanying payload, so
it must match whatever was passed to ``bc_crypt()``.
mess_id: 24-bit rolling sequence counter. Increment once per
outgoing message via ``next_mess_id()``.
encrypt: 2-byte advertisement at offset 16 in legacy headers.
Defaults to ``ENCRYPT_ADV`` (``b'\\x12\\xdc'``).
status: 2-byte status at offset 16 in modern headers. Always
0 in client requests.
payload_offset: Byte position within the payload where binary data
begins. Zero for all pure XML messages.
Returns:
Packed header bytes (20 or 24 bytes).
"""
header_len = HEADER_LENGTHS.get(message_class, 20)
magic_bytes = MAGIC.to_bytes(4, "little")
cmd_bytes = cmd_id.to_bytes(4, "little")
plen_bytes = payload_len.to_bytes(4, "little")
# mess_id field: [ch_id (1 byte)] + [mess_id (3 bytes, LE)]
mid_bytes = ch_id.to_bytes(1, "little") + mess_id.to_bytes(3, "little")
cls_bytes = message_class.to_bytes(2, "little")
if header_len == 20:
return magic_bytes + cmd_bytes + plen_bytes + mid_bytes + encrypt + cls_bytes
status_bytes = status.to_bytes(2, "little")
poff_bytes = payload_offset.to_bytes(4, "little")
return (
magic_bytes + cmd_bytes + plen_bytes + mid_bytes
+ status_bytes + cls_bytes + poff_bytes
)
# ============================================================================
# 5. SOCKET I/O
# ============================================================================
def recv_exact(sock: socket.socket, n: int) -> bytes:
"""
Read exactly *n* bytes from a blocking socket, looping until satisfied.
A single ``recv()`` call is not guaranteed to return the requested number
of bytes — particularly on congested or high-latency network paths. This
helper accumulates chunks until the full count is satisfied, making it
safe to use as the primitive for all framed protocol reads.
Args:
sock: Connected blocking socket.
n: Exact number of bytes to read.
Returns:
``bytes`` of length exactly *n*.
Raises:
ConnectionError: If the remote end closes the connection before *n*
bytes have arrived.
"""
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError(
f"Socket closed after {len(buf)} of {n} expected bytes."
)
buf += chunk
return buf
def recv_frame(sock: socket.socket) -> tuple[bytes, bytes]:
"""
Read one complete Baichuan frame (header + payload) from *sock*.
Reads in three stages:
1. **Minimum header** — 20 bytes, present in every message class.
2. **Header extension** — an additional 4 bytes for modern (24-byte)
classes, determined by the message-class field at offset 18.
3. **Payload** — ``msg_len`` bytes as reported at offset 8 in the header.
Args:
sock: Connected TCP socket positioned at the start of a Baichuan frame.
Returns:
``(header_bytes, payload_bytes)`` on success.
``(b"", b"")`` if the connection closes cleanly at the header stage
or a timeout occurs at the header stage — the caller should treat this
as a normal disconnection signal.
Raises:
ConnectionError: If the connection drops mid-frame (after the header
has been at least partially received).
"""
# Stage 1: Minimum header. A clean close or timeout here is treated as a
# normal disconnection rather than an error.
try:
header = recv_exact(sock, 20)
except (ConnectionError, socket.timeout):
return b"", b""
# Stage 2: Check the message-class field (offset 18, LE uint16) to decide
# whether a 4-byte modern header extension is present.
mclass = struct.unpack_from("<H", header, 18)[0]
header_len = HEADER_LENGTHS.get(mclass, 20)
if header_len > 20:
header += recv_exact(sock, header_len - 20)
# Stage 3: Variable-length payload.
msg_len = struct.unpack_from("<I", header, 8)[0]
payload = recv_exact(sock, msg_len) if msg_len > 0 else b""
return header, payload
def parse_header(header: bytes) -> dict:
"""
Decode a raw Baichuan header into a named-field dictionary.
Works for both 20-byte (legacy) and 24-byte (modern) headers. Fields
present only in modern headers are ``None`` for legacy headers.
Args:
header: Raw header bytes as returned by ``recv_frame()``. Must be
at least 20 bytes; 24 bytes expected for modern classes.
Returns:
Dictionary with the following keys:
``magic`` (int)
The 4-byte magic number (should always equal ``MAGIC``).
``cmd_id`` (int)
Baichuan command ID.
``msg_len`` (int)
Byte length of the payload that follows.
``ch_id`` (int)
First byte of the mess_id field (offset 12). Used as the BC
``enc_offset`` when decrypting the accompanying payload.
``mess_id`` (int)
24-bit rolling sequence counter (bytes 13–15, LE).
``enc_or_status`` (bytes)
Raw 2 bytes at offset 16 — ``encrypt`` field in legacy client
headers; ``status`` field in modern camera response headers.
``message_class`` (int)
Message class identifier (offset 18, LE uint16).
``status_code`` (int)
Bytes 16–17 interpreted as LE uint16. Meaningful in modern
camera responses: 0x00C8 = 200 OK, 0x0191 = 401 Unauthorized,
0x0190 = 400 Bad Request.
``payload_offset`` (int or None)
Byte offset where binary data begins within the payload. Present
only in 24-byte modern headers; ``None`` for 20-byte legacy headers.
"""
mclass = struct.unpack_from("<H", header, 18)[0]
return {
"magic": struct.unpack_from("<I", header, 0)[0],
"cmd_id": struct.unpack_from("<I", header, 4)[0],
"msg_len": struct.unpack_from("<I", header, 8)[0],
"ch_id": header[12],
"mess_id": int.from_bytes(header[13:16], "little"),
"enc_or_status": header[16:18],
"message_class": mclass,
"status_code": struct.unpack_from("<H", header, 16)[0],
"payload_offset": struct.unpack_from("<I", header, 20)[0]
if len(header) >= 24 else None,
}
# ============================================================================
# 6. CREDENTIAL HELPERS
# ============================================================================
def hash_credential(value: str, nonce: str) -> str:
"""
Produce the nonce-mixed credential hash for the BC XOR login path.
Used when the camera's GetNonce response contains ``<type>md5</type>``.
**Formula:** ``MD5(value + nonce)[:31].upper()``
The camera allocates a 32-byte field with a null terminator in the
LoginUser XML; only 31 characters are ever compared by the firmware.
Truncating to 31 is correct protocol behaviour, not a truncation error.
Args:
value: Plain-text username or password.
nonce: Session nonce string from the GetNonce response.
Returns:
31-character uppercase hex string.
"""
return hashlib.md5(
f"{value}{nonce}".encode("utf-8")
).hexdigest()[:31].upper()
def hash_credential_plain(value: str) -> str:
"""
Produce the plain MD5 credential hash for the AES login path.
Used when the camera's GetNonce response contains ``<type>aes</type>``.
Unlike the BC path, there is no nonce mixing — the nonce appears only in
the AES key derivation (``derive_aes_key()``), not in the credential hash.
**Formula:** ``MD5(value).hexdigest()`` (32 chars, lowercase)
Args:
value: Plain-text username or password.
Returns:
32-character lowercase hex string.
.. warning::
Unverified against physical hardware. Derived from the community
protocol specification. Test against an RLC-810A or RLC-1212A
before relying on this in production.
"""
return hashlib.md5(value.encode("utf-8")).hexdigest()
# ============================================================================
# 7. PAYLOAD BUILDERS
# ============================================================================
def build_get_nonce_payload() -> bytes:
"""
Build the minimal XML payload used to request an authentication nonce.
This is the very first message sent to a camera after the TCP connection
is established. The body contains a single self-closing ``<GetNonce/>``
element. The camera responds with an ``<Encryption>`` block containing
``<type>`` (the login encryption method the camera expects) and ``<nonce>``
(the session nonce string used in credential hashing or AES key derivation).
Returns:
UTF-8 encoded, null-terminated XML bytes ready to be appended to a
legacy (0x6514) header.
"""
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<body>\n'
'<GetNonce/>\n'
'</body>'
)
return xml.encode("utf-8") + b"\x00"
def build_login_payload(username_hash: str, password_hash: str) -> bytes:
"""
Build the LoginUser XML payload for the BC XOR login path.
Used when the nonce response advertises ``<type>md5</type>``. The
credential fields carry nonce-mixed MD5 hashes produced by
``hash_credential()``.
The ``LoginNet`` block is required — some firmware versions silently
reject logins that omit it, even though the block content (LAN / UDP
port 0) carries no meaningful information for a standard client session.
Args:
username_hash: 31-char uppercase hex from ``hash_credential()``.
password_hash: 31-char uppercase hex from ``hash_credential()``.
Returns:
UTF-8 encoded, null-terminated XML bytes ready for BC encryption
and appending to a modern (0x6414) header.
"""
xml = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
'<body>\n'
'<LoginUser version="1.1">\n'
f'<userName>{username_hash}</userName>\n'
f'<password>{password_hash}</password>\n'
'<userVer>1</userVer>\n'
'</LoginUser>\n'
'<LoginNet version="1.1">\n'
'<type>LAN</type>\n'
'<udpPort>0</udpPort>\n'
'</LoginNet>\n'
'</body>'
)
return xml.encode("utf-8") + b"\x00"
def build_aes_login_payload(username: str, password: str) -> bytes:
"""
Build the LoginUser XML payload for the AES login path.
Used when the nonce response advertises ``<type>aes</type>``. Unlike the
BC path, credentials here are plain MD5 hashes with no nonce mixing —
the nonce appears only in the AES key derivation via ``derive_aes_key()``.
This payload is passed to ``aes_encrypt()`` before transmission. The AES
key is derived separately: ``aes_key = derive_aes_key(nonce, password)``.
Args:
username: Plain-text camera username.
password: Plain-text camera password.
Returns:
UTF-8 encoded, null-terminated XML bytes (containing plain MD5 hashes)
ready for AES encryption and appending to a modern (0x6414) header.
.. warning::
Unverified against physical hardware. See ``ENC_TYPE_AES`` for the
full caveat.
"""
user_hash = hash_credential_plain(username)
pass_hash = hash_credential_plain(password)
xml = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
'<body>\n'
'<LoginUser version="1.1">\n'
f'<userName>{user_hash}</userName>\n'
f'<password>{pass_hash}</password>\n'
'<userVer>1</userVer>\n'
'</LoginUser>\n'
'<LoginNet version="1.1">\n'
'<type>LAN</type>\n'
'<udpPort>0</udpPort>\n'
'</LoginNet>\n'
'</body>'
)
return xml.encode("utf-8") + b"\x00"
def build_preview_payload(
channel_id: int = 0,
handle: int = 0,
stream_type: str = "mainStream",
) -> bytes:
"""
Build the Preview XML payload to request a video stream.
After a successful login, the client sends this payload with ``CMD_VIDEO``
to ask the camera to begin streaming. The camera responds with an
acknowledgement (XML, bare 200 OK, or implicit first frame — see
``request_stream()`` for the full taxonomy), after which all subsequent
frames on the connection carry raw Baichuan-framed media packet data.
Args:
channel_id: Camera channel index. Always 0 for single-sensor
devices. NVR channels are 0-indexed per the NVR's
channel list.
handle: Stream handle. Must be unique across concurrent streams
on the same authenticated session. Use 0 for single-
stream cases. If the camera returns 400 on a fresh
connection, try handle=1 — a stale server-side stream
slot from a previous crashed session may be occupying
handle=0.
stream_type: ``"mainStream"`` (full resolution, default) or
``"subStream"`` (reduced resolution). Production use
always selects ``"mainStream"``; ``"subStream"`` is
available for diagnostic or low-bandwidth scenarios.
Returns:
UTF-8 encoded, null-terminated XML bytes ready for BC encryption
and appending to a modern (0x6414) header.
"""
xml = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
'<body>\n'
'<Preview version="1.1">\n'
f'<channelId>{channel_id}</channelId>\n'
f'<handle>{handle}</handle>\n'
f'<streamType>{stream_type}</streamType>\n'
'</Preview>\n'
'</body>'
)
return xml.encode("utf-8") + b"\x00"
# ============================================================================
# 8. DATA MODEL
# ============================================================================
@dataclass
class DeviceInfo:
"""
Scalar fields from the ``<DeviceInfo>`` block in the login response.
A representative subset of wire fields is typed explicitly; all remaining
fields land in ``extras`` so no wire data is ever silently dropped as
firmware versions add new elements.
Two fields carry geometry metadata that must be propagated to the Session
and Stream Pipe layers:
* ``bino_type`` — non-zero for dual-sensor (Duo series) cameras. Consumers
need to know the frame is a side-by-side panoramic stitch.
* ``need_rotate`` — ``1`` on Duo 3 PoE (confirmed), meaning the encoded
frame data is rotated 90° relative to the declared display dimensions.
Decoders that ignore this produce the columnation artefacts observed in
naive RTSP pipelines.
``auth_mode`` is expected to be non-zero on cameras that negotiate the AES
login path, and may be used for post-login sanity checking once the AES
path is verified against hardware.
"""
firm_version: str = ""
type: str = "" # "ipc" / "nvr"
type_info: str = "" # "IPC"
channel_num: int = 0
audio_num: int = 0
sd_card: int = 0
soft_ver: str = ""
hard_ver: str = ""
language: str = ""
norm: str = "" # "NTSC" / "PAL"
ptz_mode: str = ""
bino_type: int = 0 # 0 = single sensor; non-zero = Duo series
need_rotate: int = 0 # 1 = frame is 90° rotated; propagate upward
auth_mode: int = 0 # 0 = BC XOR; non-zero expected for AES cameras
resolution_name: str = ""
width: int = 0
height: int = 0
extras: dict = field(default_factory=dict)
@classmethod
def from_element(cls, el: ET.Element) -> "DeviceInfo":
"""
Parse a ``<DeviceInfo>`` XML element into a ``DeviceInfo`` instance.
Unknown child elements are collected into ``extras`` rather than
discarded, ensuring no wire data is silently lost as firmware adds
new fields.
Args:
el: The ``<DeviceInfo>`` ``xml.etree.ElementTree.Element``.
Returns:
Populated ``DeviceInfo`` instance.
"""
def _int(tag: str, default: int = 0) -> int:
node = el.find(tag)
try:
return int(node.text.strip()) if node is not None and node.text else default
except ValueError:
return default
def _str(tag: str) -> str:
node = el.find(tag)
return node.text.strip() if node is not None and node.text else ""
# Tags handled by the typed fields above; everything else goes to extras.
known = {
"firmVersion", "type", "typeInfo", "channelNum", "audioNum",
"sdCard", "softVer", "hardVer", "language", "norm", "ptzMode",
"binoType", "needRotate", "authMode",
}
extras: dict = {}