Skip to content

feat(@hertzg/reolink-api): add Reolink Baichuan client for events, PTZ and every firmware command - #302

Merged
hertzg merged 19 commits into
mainfrom
hertzg/killifish
Oct 9, 2026
Merged

hertzg merged 19 commits into
mainfrom
hertzg/killifish

Conversation

@hertzg

@hertzg hertzg commented Oct 9, 2026

Copy link
Copy Markdown
Owner

New package @hertzg/reolink-api: a client for Reolink's own TCP protocol, Baichuan (port 9000). It works with cameras and doorbells that have no HTTP API.

const conn = await Deno.connect({ hostname: "192.168.1.10", port: 9000 });
const client = createClient({ readable: conn.readable, writable: conn.writable });
await client.login({ username: "admin", password: "secret" });

for await (const event of await client.subscribe())
  console.log(event); // { channel: 0, motion: true, visitor: false, tamper: false, ai: ["people"] }

const { body } = await client.call(COMMANDS.GET_NTPCFG_V20); // body.Ntp
for await (const push of client.pushes()) console.log(push.name, push.body);

What's in it

  • Events: subscribe() streams alarm state (motion, doorbell, tamper, AI types) and keeps idle connections alive with pings. Reconnecting stays with the caller.
  • Controls: playSiren / startSiren / stopSiren, privacyMode / setPrivacyMode, snapshot, ptz, ptzPresets, ptzGoToPreset, ptzPosition.
  • Every command: call(COMMANDS.X) sends any of the 257 commands the RLC-823A (IPC_523SD10.2898_23110119) and Video Doorbell PoE (DB_566128M5MP_P.4662_2508071283) firmware register, with typed request and reply bodies. Each command lists the firmware that has it.
  • Every push: pushes() streams all unsolicited messages, typed for the 9 known ones and raw XML for anything else.
  • Layers: header coder, XOR and AES-CFB ciphers, login, message framing streams and 199 XML parameter codecs are each their own entry point.

How it was checked

  • Byte-level vectors from reolink_aio's Python implementation and openssl.
  • Parameter codecs written from the firmware's own XML serializers and parsers, with a reply-element audit of every command handler.
  • A read-only run against a Video Doorbell PoE and a PTZ camera: every reply decoded and no element was left unread. A PTZ move test panned and returned to the start position.

Known limits

  • Most reply fields are optional, because one codec serves both requests and replies and the firmware accepts partial elements.
  • Enum fields reject values they don't know, so a newer firmware value fails the reply.
  • A command answered with an error status rejects without its body.

hertzg added 19 commits October 9, 2026 00:42
Reolink cameras push doorbell, motion, tamper and AI detection state over
their own TCP protocol on port 9000. No JSR package speaks it; the only
Reolink client covers the HTTP API, which has no push events.

The client logs in with the modern nonce + MD5 login, subscribes with
cmd 31 and turns cmd 33 pushes into an AlarmEvent stream. It keeps an
idle subscription alive with cmd 93 and errors the stream when the
camera stops answering, leaving reconnects to the caller.
Requests sent after the camera closed the connection used to wait
forever, and cancelling the event stream during subscribe left the
keepalive pinging. The client state is now one subscription record and
one ended reason, so every way of stopping goes through one shutdown.
…tick

The subscription and its keepalive are now created synchronously when
the event stream starts, so a concurrent subscribe sees it and throws,
and a camera that never answers the subscribe request fails it instead
of hanging.
… a slow write

Closing rejected requests whose write was still queued, before anything
listened to their reply, which surfaced as an unhandled rejection.
…fails to decode

The read loop cancelled the connection's readable when a message threw,
which closes a Deno socket the client promises not to touch.
…ocol constants

Bytes 16-17 of a Baichuan header are a status code in 24-byte headers and
an encryption marker in 20-byte ones, so every reader had to re-derive the
meaning from the message class. The header now decodes to `status` plus
`payloadOffset`, or to `encryption`, and command ids, channel ids and
message classes are exported as BAICHUAN_CMD, BAICHUAN_CHANNEL and
BAICHUAN_MESSAGE_CLASS.

BREAKING CHANGE: BaichuanHeader no longer has `code`. Read `status` on
24-byte headers and `encryption` on 20-byte headers.
…payloads

Requests about one camera carry an encrypted channel extension in front of
their body, with payloadOffset pointing past it. Replies such as snapshots
stream binary payload whose leading encryptLen bytes are AES-encrypted.
…messages

Request builders and reply parsers for cmd 263, 574/575, 109 and 18/19/190,
following reolink_aio's Baichuan XML.
… and ptz

The client gains playSiren, startSiren, stopSiren, privacyMode,
setPrivacyMode, snapshot, ptz, ptzPresets and ptzGoToPreset. These reach
battery cameras and doorbells that have no HTTP API, and features such as
privacy mode that only Baichuan exposes.
Some cameras push no PTZ state, so polling cmd 433 until pan and tilt stop
changing is the only way to tell a move has finished. Checked against a
real camera: replies take 125-260 ms and settle about 0.4 s after stop.
…them with call

Reolink firmware dispatches 225 commands, each reading and writing a fixed
set of XML parameter elements. Hand-writing a client method per command
does not scale, so parameters become typed codecs, commands become an id
plus their parameters, and one call() sends any command and decodes its
reply.

The parameter codecs for the RLC-823A firmware start as placeholders, to
be filled from the firmware's serializers.
…A and Video Doorbell PoE

Each XML parameter element either firmware reads or writes now has a codec
recovered from the firmware's serializers and parsers, 199 in all. The
command table covers the 257 commands the two builds register, tags each
with the builds that have it, and adds the elements a reply was found to
carry instead of the listed ones, so call() decodes them.

pushes() streams every message the camera sends unasked, decoded by a push
table, and passes unknown ones through as raw XML so nothing is dropped.
…ra firmware lacks

A live read-only run against a doorbell failed TalkAbility on
mixAudioStream, a stream mode only the doorbell firmware has. Checking
every enum and field against the doorbell firmware found three more:
the package AI type, the doorbell button light state in LedState, which
was dropped silently, and the aiYuvData preview stream with its fields.
…erver adds

A read-only run against a doorbell listed reply elements no codec read.
The doorbell builds these replies in netserver rather than the shared
library, so they were missing from the library evidence: recordAbility,
smartRecord and talkRecord in RecordCfg, itemNo and aiVersion in
VersionInfo, visitorLoudspeaker in audioCfg, and richNotificationEnable
and consentAgreement in PushCfg.
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Docs preview: https://hertzg.github.io/jsr-monorepo/pr/302/

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Benchmark Results

No significant changes detected. 31 benchmark(s) within their noise floor.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.11998% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.78%. Comparing base (dbc242e) to head (1e729b7).

Files with missing lines Patch % Lines
packages/@hertzg/reolink-api/client.ts 91.35% 21 Missing and 9 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #302      +/-   ##
==========================================
+ Coverage   96.79%   97.78%   +0.98%     
==========================================
  Files         159      374     +215     
  Lines        8499    13794    +5295     
  Branches     1300     1458     +158     
==========================================
+ Hits         8227    13489    +5262     
- Misses        152      175      +23     
- Partials      120      130      +10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hertzg
hertzg merged commit 6103d4e into main Oct 9, 2026
7 checks passed
@hertzg
hertzg deleted the hertzg/killifish branch October 9, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants