Kami mendapatkan video dari kamera melalui DVRIP menggunakan PHP

Dalam artikel sebelumnya, saya berjanji untuk menunjukkan skrip yang menarik video dari kamera dan, meskipun beberapa waktu telah berlalu sejak itu, janji-janji itu harus dipenuhi. Jadi saya melakukannya.

Kebetulan bahwa dengan asynchrony di dunia web server semuanya terkait, tetapi tidak PHP.

Nah, karena, Anda tahu, model yang sekarat ini, kebocoran memori, dan memang tidak ada yang keluar dari kotak di PHP kecuali stream_select () dan stream_set_blocking () .

Di suatu tempat di sana, di PECL, ada semacam libuv , yang, pada prinsipnya, hanyalah pembungkus untuk fungsi asli dari perpustakaan asli, jadi menggunakannya seperti itu memberi Anda beberapa tantangan. Lagi pula, siapa yang waras yang akan melakukan ini?

Tetapi jika kita berhenti hidup di dunia PHP4 dan kembali ke realitas modern sedikit, kita akan melihat bahwa beberapa hal telah berubah dalam beberapa tahun terakhir. Kami memiliki alat yang menarik seperti ReactPHP dan AmPHP , yang komponennya mencakup fungsionalitas Node.js dengan baik, dan keberadaan generator memungkinkan kami untuk menulis kode asinkron dengan gaya yang nyaman, seperti async / menunggu , menghindari semua panggilan balik tanpa akhir ini dalam panggilan balik dan rantai kilometer . ). kemudian () . lalu () .

Jadi itu sebabnya sekarang, menurut saya, praktis tidak ada tugas dari dunia Node.js yang tidak bisa diselesaikan oleh PHP. Tetapi jika masih ada beberapa, maka semuanya hanya tergantung pada kehadiran beberapa perpustakaan terpisah, dan bukan kurangnya kesempatan seperti itu.
Mungkin mengejutkan orang untuk mengetahui bahwa perpustakaan standar PHP sudah memiliki semua yang kita butuhkan untuk menulis aplikasi yang digerakkan oleh peristiwa dan non-pemblokiran. Kami hanya mencapai batas fungsionalitas PHP asli di area ini ketika kami memintanya untuk mensurvei ribuan deskriptor file untuk aktivitas IO secara bersamaan. Meskipun dalam kasus ini, kesalahannya bukan pada PHP tetapi sistem yang mendasari pilih () panggilan yang linier dalam penurunan kinerjanya seiring dengan meningkatnya beban.

amphp.org/amp/event-loop

Tentu saja, pertanyaan menggunakan ini semua dalam produksi belum sepenuhnya ditutup, tetapi jika Anda tidak sengaja salah hal bahwa lingkungan runtime asinkron lainnya tidak akan memaafkan Anda, maka semuanya akan baik-baik saja.

DVRIP


Kami akan mempertimbangkan protokol, yang konon ditemukan oleh orang Cina, yang digunakan untuk berkomunikasi perangkat lunak dengan kamera pengintai.

Semuanya bekerja di atas TCP, disebut DVRIP (kadang-kadang Sofia), dan pada saat saya membutuhkannya, saya hanya menemukan dua perpustakaan yang lebih atau kurang waras, salah satunya dijelaskan secara umum hanya menghubungkan ke kamera dan bertukar beberapa pesan, tetapi memiliki deskripsi header paket yang banyak membantu saya.

Yang kedua ditemukan sedikit kemudian, pada saat eksaserbasi sindrom saya tidak ditemukan di sini, dan itu bekerja sedikit berbeda dari yang saya inginkan.

Secara umum, dipersenjatai dengan sniffer dan deskripsi header paket, saya secara singkat membuat sketsa skrip tertentu yang berputar di suatu tempat di server saya di suatu tempat, dan akan disajikan dalam bukti konsep tertentu dalam artikel ini.

Protokol itu sendiri tidak kompleks dan paketnya terlihat seperti ini:

  • header 20 byte, yang dalam PHP dapat ditunjuk sebagai

    unpack('Chead/Cversion/x2/IsessionId/Isequence/x2/SmsgId/Ilen', $buffer) 
  • header diikuti oleh pesan len panjang dan dapat berisi json, ditambah dengan dua byte \ x0a \ x00, atau data biner.

Kami hanya akan mengirimkan json, tetapi kami akan menerima keduanya.

Jawabannya (jika itu adalah paket dengan json) biasanya berisi bidang Ret yang menunjukkan keberhasilan permintaan kami. Respons yang berisi kode Ret yang tidak sama dengan 100 atau 515 akan dianggap sengaja salah.

Berbekal pengetahuan dasar ini, mari kita terhubung ke kamera dan mengerti.
Kita membutuhkan dua perpustakaan - amphp / socket , yang akan menarik hampir semua yang kita butuhkan, dan evenement / evenement , yang tersedia dalam kemasan dan tidak memerlukan ekstensi.

Bekerja dengan TCP di Amp terlihat seperti ini:

 Loop::run(function () { $socket = yield connect("tcp://{$addr}:{$port}"); yield $socket->write('Hello'); while ($chunk = yield $socket->read()) { echo $chunk; } $socket->close(); }); 

DVRIP biasanya berjalan pada port 34567, jadi dalam kasus kami akan menjadi seperti ini:

 $socket = yield connect('tcp://192.168.0.200:34567'); 

Selanjutnya, kami masuk dengan mengirimkan hash nama pengguna dan kata sandi kami ke kamera, yang dihitung sebagai berikut:

 function sofiaHash(string $password) : string { $md5 = md5($password, true); return implode('', array_map(function ($i) use ($md5) { $c = (ord($md5[2 * $i]) + ord($md5[2 * $i + 1])) % 62; $c += $c > 9 ? ($c > 35 ? 61 : 55) : 48; return chr($c); }, range(0, 7))); } 

Jika otorisasi berhasil, kami meminta video dan, jika kamera dapat memberikannya kepada kami, maka kami mulai mentransmisikannya.

Setiap jenis paket memiliki pesan tersendiri, sehingga kami dapat memahami permintaan apa yang kami terima jawabannya. Secara umum, bukan pekerjaan yang berdebu.

Untuk kenyamanan, mari kita jelaskan kelas untuk paket kami:

 class Packet { public const SUCCESS_CODES = [100, 515]; public const MESSAGE_CODES = [ 'packet.request.login' => 1000, 'packet.request.keepAlive' => 1006, 'packet.request.claim' => 1413, 'packet.response.login' => 1001, 'packet.response.keepAlive' => 1007, 'packet.response.claim' => 1414, 'packet.videoControl' => 1410, 'packet.binary' => 1412, ]; protected int $head = 255; protected int $version = 0; protected int $sessionId; protected int $sequence; protected int $msgId; protected ?string $rawData; protected ?array $data; public function __construct(int $msgId, ?array $data = null, int $sequence = 0, int $sessionId = 0) { $this->msgId = $msgId; $this->data = $data; $this->sequence = $sequence; $this->sessionId = $sessionId; } public function __toString() : string { if ($this->data !== null) { $this->rawData = json_encode($this->data, JSON_THROW_ON_ERROR) . "\x0a\x00"; } return pack('CCx2IIx2SI', $this->head, $this->version, $this->sessionId, $this->sequence, $this->msgId, strlen($this->rawData)) . $this->rawData; } public function getData() : ?array { return $this->data; } public function getRawData() : ?string { return $this->rawData; } public function getSession() : int { return $this->sessionId; } public function getSequence() : int { return $this->sequence; } public function getMessageType() : string { $types = array_flip(static::MESSAGE_CODES); if (!isset($types[$this->msgId])) { throw new \Exception('Unknown message type: ' . $this->msgId); } return $types[$this->msgId]; } } 

dan mencoba mengirim permintaan otorisasi

 yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.request.login'], [ 'EncryptType' => 'MD5', 'LoginType' => 'DVRIP-Web', 'PassWord' => sofiaHash('admin'), 'UserName' => 'admin', ])); 

Ya bagus Kami terhubung dan mengirim pesan, tetapi kami bahkan tidak tahu apakah kamera itu benar dan bereaksi setidaknya. Kita perlu entah bagaimana membaca jawabannya (mari kita lakukan secara serempak), pilih paket tertentu di dalamnya dan dekode mereka.

Untuk memisahkan logika pemrosesan paket yang datang sebagai tanggapan, saya memutuskan untuk menggunakan perpustakaan evenement dan membuat sketsa kelas berdasarkannya yang akan membuang paket-paket yang sudah didekodekan

 class Reader extends EventEmitter { protected InputStream $socket; public function __construct(InputStream $socket) { $this->socket = $socket; } public function start() : \Generator { $buffer = ''; while (!$this->socket->isClosed() && $result = yield Packet::read($this->socket, $buffer)) { [$packet, $chunk] = $result; $buffer = $chunk; $chunk = null; $this->emit('packet', [$packet]); $this->emit($packet->getMessageType(), [$packet]); } $buffer = null; } } 

dan juga menambahkan beberapa metode statis ke kelas Paket kami, yang, pada kenyataannya, akan mengekstrak dan mendekode paket dari aliran data

 public static function read(InputStream $socket, string $buffer = '') : Promise { return call(function () use ($socket, $buffer) { $header = null; do { if ($header === null && strlen($buffer) > 20) { $header = unpack('Chead/Cversion/x2/IsessionId/Isequence/x2/SmsgId/Ilen', substr($buffer, 0, 20)); $buffer = substr($buffer, 20); } if ($header !== null && strlen($buffer) >= (int) $header['len']) { return [ static::fromRaw($header, (int) $header['len'] > 0 ? substr($buffer, 0, (int) $header['len']) : null), substr($buffer, (int) $header['len']) ]; } $buffer .= yield $socket->read(); } while (!$socket->isClosed()); }); } protected static function fromRaw(array $header, ?string $rawData) : self { $packet = new static($header['msgId'], null, $header['sequence'], $header['sessionId']); $packet->rawData = $rawData; $packet->data = (int) $packet->msgId === static::MESSAGE_CODES['packet.binary'] || $rawData === null ? null : json_decode(substr($rawData, 0, -2), true, 512, JSON_THROW_ON_ERROR); return $packet; } 

Sekarang, melalui penggunaan Evenement , kita dapat menggambarkan logika kita dalam bentuk

 $reader->on($packetType, $handler); 

Dan jadi saya mendapatkan rantai ini, di mana saya menambahkan pemrosesan Keep Alive dan SIGINT / SIGTERM lainnya:

 $reader = new Reader($socket = yield connect('tcp://10.0.5.100:49152')); $reader->on('packet.response.login', asyncCoroutine(function (Packet $packet) use ($socket) { if (empty($packet->getData()['Ret']) || !in_array($packet->getData()['Ret'], Packet::SUCCESS_CODES)) { throw new \Exception('Wrong login data'); } yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.request.claim'], [ 'Name' => 'OPMonitor', 'OPMonitor' => ['Action' => 'Claim', 'Parameter' => ['Channel' => 0, 'CombinMode' => 'NONE', 'StreamType' => 'Main', 'TransMode' => 'TCP']], ], 1, $packet->getSession())); })); $reader->once('packet.response.login', function (Packet $packet) use ($socket) { Loop::unreference(Loop::repeat($packet->getData()['AliveInterval'] * 1000, function ($watcherId) use ($socket, $packet) { if ($socket->getResource() === null) { return Loop::cancel($watcherId); } yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.request.keepAlive'], [ 'Name' => 'KeepAlive', 'SessionID' => $packet->getData()['SessionID'] ], 0, $packet->getSession())); })); }); $reader->on('packet.response.claim', asyncCoroutine(function (Packet $packet) use ($socket) { yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.videoControl'], [ 'Name' => 'OPMonitor', 'OPMonitor' => ['Action' => 'Start', 'Parameter' => ['Channel' => 0, 'CombinMode' => 'NONE', 'StreamType' => 'Main', 'TransMode' => 'TCP']], ], $packet->getSequence() + 1, $packet->getSession())); })); $emitter = new Emitter(); $reader->on('packet.binary', function ($packet) use ($emitter) { $emitter->emit($packet->getRawData()); }); $reader->once('packet.binary', function (Packet $packet) use ($socket) { $signalHandler = function () use ($socket, $packet) { yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.videoControl'], [ 'Name' => 'OPMonitor', 'OPMonitor' => ['Action' => 'Stop', 'Parameter' => ['Channel' => 0, 'CombinMode' => 'NONE', 'StreamType' => 'Main', 'TransMode' => 'TCP']], ], 0, $packet->getSession())); $socket->close(); }; Loop::unreference(Loop::onSignal(defined('SIGINT') ? SIGINT : 2, $signalHandler, 'SIGINT')); Loop::unreference(Loop::onSignal(defined('SIGTERM') ? SIGTERM : 15, $signalHandler, 'SIGTERM')); }); asyncCall([$reader, 'start']); yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.request.login'], [ 'EncryptType' => 'MD5', 'LoginType' => 'DVRIP-Web', 'PassWord' => sofiaHash('123qwea'), 'UserName' => 'admin', ])); yield pipe(new IteratorStream($emitter->iterate()), getStdout()); 

Tak perlu dikatakan bahwa alih-alih stdout, kita dapat mengarahkan aliran video ke mana pun kita inginkan.

Naskah akhir
 <?php ini_set('display_errors', 'stderr'); use Amp\Emitter; use Amp\Loop; use Amp\Promise; use Amp\ByteStream\InputStream; use Amp\ByteStream\IteratorStream; use Evenement\EventEmitter; use function Amp\call; use function Amp\asyncCall; use function Amp\asyncCoroutine; use function Amp\ByteStream\getStderr; use function Amp\ByteStream\getStdout; use function Amp\ByteStream\pipe; use function Amp\Socket\connect; require 'vendor/autoload.php'; function sofiaHash(string $password) : string { $md5 = md5($password, true); return implode('', array_map(function ($i) use ($md5) { $c = (ord($md5[2 * $i]) + ord($md5[2 * $i + 1])) % 62; $c += $c > 9 ? ($c > 35 ? 61 : 55) : 48; return chr($c); }, range(0, 7))); } class Reader extends EventEmitter { protected InputStream $socket; public function __construct(InputStream $socket) { $this->socket = $socket; } public function start() : \Generator { $buffer = ''; while (!$this->socket->isClosed() && $result = yield Packet::read($this->socket, $buffer)) { [$packet, $chunk] = $result; $buffer = $chunk; $chunk = null; $this->emit('packet', [$packet]); $this->emit($packet->getMessageType(), [$packet]); } $buffer = null; } } class Packet { public const SUCCESS_CODES = [100, 515]; public const MESSAGE_CODES = [ 'packet.request.login' => 1000, 'packet.request.keepAlive' => 1006, 'packet.request.claim' => 1413, 'packet.response.login' => 1001, 'packet.response.keepAlive' => 1007, 'packet.response.claim' => 1414, 'packet.videoControl' => 1410, 'packet.binary' => 1412, ]; public static function read(InputStream $socket, string $buffer = '') : Promise { return call(function () use ($socket, $buffer) { $header = null; do { if ($header === null && strlen($buffer) > 20) { $header = unpack('Chead/Cversion/x2/IsessionId/Isequence/x2/SmsgId/Ilen', substr($buffer, 0, 20)); $buffer = substr($buffer, 20); } if ($header !== null && strlen($buffer) >= (int) $header['len']) { return [ static::fromRaw($header, (int) $header['len'] > 0 ? substr($buffer, 0, (int) $header['len']) : null), substr($buffer, (int) $header['len']) ]; } $buffer .= yield $socket->read(); } while (!$socket->isClosed()); }); } protected static function fromRaw(array $header, ?string $rawData) : self { $packet = new static($header['msgId'], null, $header['sequence'], $header['sessionId']); $packet->rawData = $rawData; $packet->data = (int) $packet->msgId === static::MESSAGE_CODES['packet.binary'] || $rawData === null ? null : json_decode(substr($rawData, 0, -2), true, 512, JSON_THROW_ON_ERROR); return $packet; } protected int $head = 255; protected int $version = 0; protected int $sessionId; protected int $sequence; protected int $msgId; protected ?string $rawData; protected ?array $data; public function __construct(int $msgId, ?array $data = null, int $sequence = 0, int $sessionId = 0) { $this->msgId = $msgId; $this->data = $data; $this->sequence = $sequence; $this->sessionId = $sessionId; } public function __toString() : string { if ($this->data !== null) { $this->rawData = json_encode($this->data, JSON_THROW_ON_ERROR) . "\x0a\x00"; } return pack('CCx2IIx2SI', $this->head, $this->version, $this->sessionId, $this->sequence, $this->msgId, strlen($this->rawData)) . $this->rawData; } public function getData() : ?array { return $this->data; } public function getRawData() : ?string { return $this->rawData; } public function getSession() : int { return $this->sessionId; } public function getSequence() : int { return $this->sequence; } public function getMessageType() : string { $types = array_flip(static::MESSAGE_CODES); if (!isset($types[$this->msgId])) { throw new \Exception('Unknown message type: ' . $this->msgId); } return $types[$this->msgId]; } } Loop::run(function () : \Generator { $reader = new Reader($socket = yield connect('tcp://10.0.5.100:49152')); $reader->on('packet.response.login', asyncCoroutine(function (Packet $packet) use ($socket) { if (empty($packet->getData()['Ret']) || !in_array($packet->getData()['Ret'], Packet::SUCCESS_CODES)) { throw new \Exception('Wrong login data'); } yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.request.claim'], [ 'Name' => 'OPMonitor', 'OPMonitor' => ['Action' => 'Claim', 'Parameter' => ['Channel' => 0, 'CombinMode' => 'NONE', 'StreamType' => 'Main', 'TransMode' => 'TCP']], ], 1, $packet->getSession())); })); $reader->once('packet.response.login', function (Packet $packet) use ($socket) { Loop::unreference(Loop::repeat($packet->getData()['AliveInterval'] * 1000, function ($watcherId) use ($socket, $packet) { if ($socket->getResource() === null) { return Loop::cancel($watcherId); } yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.request.keepAlive'], [ 'Name' => 'KeepAlive', 'SessionID' => $packet->getData()['SessionID'] ], 0, $packet->getSession())); })); }); $reader->on('packet.response.claim', asyncCoroutine(function (Packet $packet) use ($socket) { yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.videoControl'], [ 'Name' => 'OPMonitor', 'OPMonitor' => ['Action' => 'Start', 'Parameter' => ['Channel' => 0, 'CombinMode' => 'NONE', 'StreamType' => 'Main', 'TransMode' => 'TCP']], ], $packet->getSequence() + 1, $packet->getSession())); })); $emitter = new Emitter(); $reader->on('packet.binary', function ($packet) use ($emitter) { $emitter->emit($packet->getRawData()); }); $reader->once('packet.binary', function (Packet $packet) use ($socket) { $signalHandler = function () use ($socket, $packet) { yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.videoControl'], [ 'Name' => 'OPMonitor', 'OPMonitor' => ['Action' => 'Stop', 'Parameter' => ['Channel' => 0, 'CombinMode' => 'NONE', 'StreamType' => 'Main', 'TransMode' => 'TCP']], ], 0, $packet->getSession())); $socket->close(); }; Loop::unreference(Loop::onSignal(defined('SIGINT') ? SIGINT : 2, $signalHandler, 'SIGINT')); Loop::unreference(Loop::onSignal(defined('SIGTERM') ? SIGTERM : 15, $signalHandler, 'SIGTERM')); }); asyncCall([$reader, 'start']); yield $socket->write((string) new Packet(Packet::MESSAGE_CODES['packet.request.login'], [ 'EncryptType' => 'MD5', 'LoginType' => 'DVRIP-Web', 'PassWord' => sofiaHash('123qwea'), 'UserName' => 'admin', ])); yield pipe(new IteratorStream($emitter->iterate()), getStdout()); }); 

Sekarang kita dapat mencoba merekam video dengan cara ini

 $ php recorder.php > output.h264 

Kami menambahkan pengendali SIGINT, jadi ketika Anda lelah, cukup tekan Ctrl + C dan script akan menghentikan transfer video dan menutup koneksi secara normal.

Dan kita dapat, misalnya, mengarahkan aliran ke ffmpeg dan transcode dengan cepat.

 $ php recorder.php | ffmpeg -nostdin -y -hide_banner -loglevel verbose -f h264 -i pipe:0 -pix_fmt yuv420p -c copy -movflags +frag_keyframe+empty_moov+default_base_moof -reset_timestamps 1 -f mpegts output.mpegts 

Dan Anda bisa membuat rantai bentuk panik

 $ php recorder.php | ffmpeg ... | curl -d @- ... 

meluncurkan semua ini dengan semacam skrip yang akan dimulai kembali setiap jam, jadi kami mendapatkan potongan halus yang berlangsung 1 jam, dengan cepat, di mana pun kami butuhkan.

Mengapa


Saya cenderung percaya bahwa artikel ini akan menimbulkan pertanyaan "Mengapa?" Ada solusi siap pakai untuk pendaftaran dari kamera, kamera pada umumnya dapat melakukan banyak hal sendiri, karena mereka benar keberatan dalam komentar pada artikel sebelumnya, dan secara umum itu bisa dibuat lebih mudah.

Hanya saja saya punya waktu luang yang harus saya lakukan di suatu tempat, keinginan untuk membuat jalan sendiri dan keinginan yang tak kenal lelah untuk eksperimen.

Dan itu berhasil.

Tentu saja, skrip yang disajikan dalam artikel memerlukan beberapa modifikasi, dan Anda tidak boleh menggunakannya dalam bentuk ini - saya ulangi, ini adalah bukti konsep. Dan secara umum, saya sangat memperingatkan Anda untuk menggunakan produk buatan rumah setinggi lutut untuk memastikan keamanan rumah Anda.
Dalam kasus saya, keandalan tidak terlalu penting, jadi opsi ini akan berfungsi. Seorang pengamat skrip memutar skrip ini, yang mentransfer aliran ke ffmpeg, merekam potongan per jam dan mengunggahnya ke VK, memulai kembali skrip jika terjadi pemutusan atau keadaan yang tidak terduga.

Secara umum, semua ini bisa dihindari dengan hanya mengeluarkan video melalui RTSP melalui TCP, seperti yang saya sarankan di akhir artikel sebelumnya, tetapi ffmpeg suka menunda dengan bodoh pada saat acak dalam waktu dan mulai merespons hanya kepada SIGKILL.

Saya mulai curiga bahwa semua ini karena arloji skrip induk dijalankan oleh mahkota dan menerima prioritas yang lebih rendah, tetapi meningkatkan prioritas hanya mengurangi frekuensi masalah.

Dalam kasus skrip di atas, semuanya bekerja jauh lebih stabil dan belum mengajukan keluhan.

Source: https://habr.com/ru/post/id484518/


All Articles