I'm building DIY home surviellance system with 30 ESP32 AI Thinker cams. To join and manage all of them I create a web-application of NodeJS - a dashboard to view all cams, add/edit/delete them. Each camera is a ESP32 chip which streams JPEG video (jpeg frames once in X ms). Each camera has its own IP address in home network, so that means each camera has a simple web server to watch a video. But since this is a ESP32 and a simple web server - there cannot be 2 or more connection to web server. Let's say I have a browser tab connected to the streamer, and when I duplicate that tab, the loading takes endless time and right after I close first tab - the second tab shows the video. The cameras dashboard should be able to show to multiple connection, but having the issue with ESP32 web connection I decided to make som kind of re-streamer on python or nodejs, which will "read" from all cameras (so each camera will have 1 connection) and re-stream to multiple connections.
Again, ESP32 cams stream, re-streamer read each cam and re-streams, the dashboard reads from re-streamer
The dashboard is ready - it's just an ordinary CRUD with additional service - watch a camera
The ESP32 is ready.
There left to write that re-streamer and I spent aboth a week to find a solution. The problem is that nothing I tried can read the JPEG steram. Yes, this is a JPEG stream, not, say, RTSP or HSP protocol. When I curl IP of cam I see content type image/jpeg.
Can someone tell me how to read and re-translate the JPEG stream so I can show it in dashboard? FFmpeg didn't help
So after a month I finally found a solution. The result is here: https://github.com/Electr0Hub/HomeSauron-Aggregator
Made the follofing way: There is a command which is a process (kind of laravel artisan commands). That command wakes-up, gets all cameras from DB, goes through all the cameras with loop and calls another command for each camera, i.e. runs independed commands and passes the camera ID to that command. The child one (which is independed) gets the camera URL from DB using its ID and does its endless read and writes chunks into Redis pub/sub. So in general I have one "parent" command which triggers all "children" commands to wakeup and read and write into Redis. Very simple.
I made it in Laravel with its Artisan commands. So I have:
php artisan restream:cameras
public function handle()
{
// Kill all previous instances of the `restream:camera` command
$this->killPreviousProcesses();
// Retrieve all cameras
$cameras = Camera::select('id')->get();
foreach ($cameras as $camera) {
$cameraId = $camera->id;
$this->info("Starting restream for camera ID: $cameraId");
// Execute the restream:camera command in the background
exec("php artisan restream:camera --camera={$cameraId} > /dev/null &");
}
}
/**
* Kill all previous instances of the `restream:camera` command.
*/
protected function killPreviousProcesses(): void
{
// Get all the PIDs of `restream:camera` processes
exec("ps aux | grep 'php artisan restream:camera ' | grep -v grep | awk '{print $2}'", $output);
foreach ($output as $pid) {
// Kill each process
exec("kill -9 $pid");
}
}
And a child one - php artisan restream:camera --camera={CAMERAID}
public function handle()
{
try {
// Retrieve the camera option
$cameraOption = $this->option('camera');
// Check if the camera option is provided
if (!$cameraOption) {
$this->error('The --camera option is required.');
return;
}
// Fetch the camera(s) based on the option
$camera = Camera::find($cameraOption);
if (is_null($camera)) {
$this->error('No cameras found with the provided option.');
return;
}
$client = new Client();
$this->restreamCamera($client, $camera);
}
catch (\Exception $exception) {
Log::channel("streams")->error($exception);
throw $exception;
}
}
protected function restreamCamera(Client $client, Camera $camera): void
{
$url = $camera->url;
$client->getAsync($url, [
'stream' => true,
'sink' => fopen('php://temp', 'r+')
])->then(
function (Response $response) use ($camera) {
$this->info('Streaming frame from ' . $camera->id);
$body = $response->getBody();
$frameBuffer = '';
while (!$body->eof()) {
// Read a chunk of data
$frame = $body->read(1024); // Adjust the buffer size as needed
$frameBuffer .= $frame;
// Check if the buffer contains more than one boundary
if (substr_count($frameBuffer, '123456789000000000000987654321') > 1) {
// Extract data between boundaries
$jpegData = $this->getDataBetweenBoundary($frameBuffer, '123456789000000000000987654321', '123456789000000000000987654321');
// Find the JPEG start marker
$jpegStart = strpos($jpegData, "\xFF\xD8");
if ($jpegStart !== false) {
// Extract the JPEG data from the start marker
$jpegData = substr($jpegData, $jpegStart);
// Publish the JPEG data to Redis
$dataToPublish = [
'frame' => base64_encode($jpegData),
'camera' => [
'id' => $camera->id,
'name' => $camera->name,
'url' => $camera->url,
],
];
Redis::publish("camera_stream:$camera->id", json_encode($dataToPublish));
// Reset the buffer to remove processed data
$frameBuffer = substr($frameBuffer, strpos($frameBuffer, '123456789000000000000987654321', strpos($frameBuffer, '123456789000000000000987654321') + 1));
}
}
}
},
function ($error) {
// Handle the error
echo "Error: " . $error->getMessage();
}
);
}
protected function getDataBetweenBoundary($string, $start, $end){
$string = ' ' . $string;
$ini = strpos($string, $start);
if ($ini == 0) return '';
$ini += strlen($start);
$len = strpos($string, $end, $ini) - $ini;
return substr($string, $ini, $len);
}