Guía para Crear un Sistema de Seguridad con Cámaras IP y una Raspberry Pi: Construye tu Propio Sistema de Vigilancia Domiciliaria


Última revisión en abril del 2022.
Esta entrada explica cómo hacer que una Raspberry Pi cuando detecta movimiento con un sensor de infrarrojos, haga una foto con una cámara IP y la envíe a nuestro móvil usando una notificación.

Puedes encontrar el código de la Raspberry Pi de esta entrada en el repositorio de GitHub: Consolidando - Raspberry.

El proyecto

El proyecto consta de una Raspberry PI, de un módulo de detección de AC, de un módulo de relés, de una cámara IP, de un sensor de movimiento y un foco. Su conexionado es idéntico al de la entrada Detectando Corriente Alterna - Sensor de Movimiento.

Además, tenemos un servidor Pub/Sub en la Google Cloud Platform y usaremos el Firebase Cloud Messaging. También tenemos una App cliente para recibir notificaciones / alarmas en los móviles.

La cámara IP, la Raspberry PI, el servidor Pub/Sub (en la red local o en la nube) y los móviles (en alguna red de datos) están conectados a través del router. Los dispositivos que están en la red local se les asigna una dirección IP privada fija para facilitar su localización.

Tecnologías Software Utilizadas

  • Google Appengine - Java
  • Eclipse Jetty
  • Eclipse Jersey
  • Firebase Cloud Messaging
  • Google Sign In
  • OpenSSL
  • Google Cloud Storage
  • Maven
  • Vanilla JS
  • Android - Java

El hardware

  • Raspberry PI 4 Model B
  • Cámara IP
  • Sensores de Movimiento
  • Módulo de Relés con optoacopladores
  • Módulo de detección de AC
  • Focos
  • Un móvil Android

Detección de movimiento

En la entrada Detectando Corriente Alterna - Sensor de Movimiento se explica cómo se usan las GPIO de la Raspberry Pi para para detectar movimiento a través de un sensor de infrarrojos alimentado en alterna.

Tomar una foto con la Cámara IP

En la entrada Cómo hacer una foto con una cámara IP se explica cómo usar el protocolo RTSP para sacar una foto del streaming de una cámara IP.

Enviar una notificación con una imagen a un móvil

Esta parte se basa en la entrada Detectando Corriente Alterna - Sensor de Movimiento donde se explica como enviar notificaciones a los móviles, pero en este caso se incluye una imagen jpeg del movimiento detectado que es un archivo binario que no podemos empotrar en un objeto json. Así optamos por crear un nuevo método notification/image en el recuso alarm en el servidor Pub/Sub. Este método acepta contenido multipart/form-data del cual incluimos debajo el detalle de la implementación en el cliente.

Recurso alarm - método notification/image

La llamada del método notification/image del recurso alarm en la Raspberry, implementado en lenguaje C.


#include "rtsp.h"
#include "rest.h"
#include "jwt_create.h"

const char *PROJECT_ID = "Project Id";
const char *DEVICE_ID = "MyFistDeviceID";
const char *RSA_PRIVATE_KEY_NAME = "rsa_private.pem";

#define TMP_FILE "pictureName.jpg"

// Pub/Sub host
const char *HOST_NAME = "192.168.1.36";
const char *HOST_PORT = "8080";
const char *HOST_RESOUCE = "resource/alarm/notification/image";

// IP CAM Host
const char *IP_CAM_RESOURCE = "rtsp://192.168.1.24:554/1/h264major";

const notificationStruct notificationBody =
    {
        .title = "IP CAM Alarm",
        .body = "Motion Detected",
        .image = TMP_FILE};

int alarmTakePictureAndSendNotification()
{
    RTSPResource resource;
    int ret;
    char *jwt;
    int socket;

    // opens ip cam resource
    ret = openIpCam(IP_CAM_RESOURCE, &resource);
    if (ret == 0)
        ret = openVideoCodec(&resource);
    // gets an image from the camera
    if (ret == 0)
        ret = takeAPicture(&resource, TMP_FILE, 9, 0);

    // releases resource
    closeVideoCodec(&resource);
    closeIpCam(&resource);

    if (ret == 0)
    {
        // creates authentification token for the device id
        jwt = jwtCreate(RSA_PRIVATE_KEY_NAME, PROJECT_ID, DEVICE_ID);

        // sends image notification to the subscribed mobiles
        restPostMultipart(HOST_NAME, HOST_PORT, HOST_RESOUCE, &notificationBody, jwt);
    }

    return(ret);
}

Detalle de cómo se forma el multipart/form-data con los datos de la notificación.


#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

#include "rest.h"

#define TIMEOUT 5.0
#define USER_AGENT "RPI4B"
const char *BOUNDARY = "xxBOUNDARYxx";

int receiveRespose(int server);

int multiPartBody(unsigned char **pBuffer,
                  const notificationStruct *notificationBody)
{
    FILE *file;
    unsigned long fileLen;
    const int boundaryLen = strlen(BOUNDARY);
    unsigned char *buffer;

    // Open file
    file = fopen(notificationBody->image, "rb");
    if (!file)
    {
        fprintf(stderr, "Unable to open file %s", notificationBody->image);
        return (0);
    }

    // Get file length
    fseek(file, 0, SEEK_END);
    fileLen = ftell(file);
    fseek(file, 0, SEEK_SET);

    // Allocate memory
    buffer = (char *)malloc(fileLen + 512);
    if (!buffer)
    {
        fprintf(stderr, "Memory error!");
        fclose(file);
        return 0;
    }

    sprintf(buffer, "--%s\r\n", BOUNDARY);
    sprintf(buffer + strlen(buffer), "Content-Disposition: form-data; name=\"title\"\r\n\r\n");
    sprintf(buffer + strlen(buffer), "%s\r\n", notificationBody->title);
    sprintf(buffer + strlen(buffer), "--%s\r\n", BOUNDARY);
    sprintf(buffer + strlen(buffer), "Content-Disposition: form-data; name=\"body\"\r\n\r\n");
    sprintf(buffer + strlen(buffer), "%s\r\n", notificationBody->body);
    sprintf(buffer + strlen(buffer), "--%s\r\n", BOUNDARY);
    sprintf(buffer + strlen(buffer), "Content-Disposition: form-data; name=\"image\"; filename=\"%s\"\r\n", notificationBody->image);
    sprintf(buffer + strlen(buffer), "Content-Type: image/jpeg\r\n\r\n");
    int len = strlen(buffer);

    fread(buffer + len, fileLen, sizeof(unsigned char), file);
    fclose(file);
    len = len + fileLen;
    sprintf(buffer + len, "\r\n--%s--\r\n", BOUNDARY);
    len = len + boundaryLen + 8;

    *pBuffer = buffer;

    return (len);
}

void sendPostMultipart(int socket, const char *hostname,
                       const char *port,
                       const char *path,
                       char *jwt,
                       const notificationStruct *notificationBody)
{
    char header[1024];
    unsigned char *buffer = NULL;
    int len;

    len = multiPartBody(&buffer, notificationBody);

    sprintf(header, "POST /%s HTTP/1.1\r\n", path);
    sprintf(header + strlen(header), "Host: %s:%s\r\n", hostname, port);
    sprintf(header + strlen(header), "Connection: close\r\n");
    sprintf(header + strlen(header), "User-Agent: %s\r\n", USER_AGENT);
    sprintf(header + strlen(header), "Content-Type: multipart/form-data; boundary=%s\r\n", BOUNDARY);
    sprintf(header + strlen(header), "Authorization: Bearer %s\r\n", jwt);
    sprintf(header + strlen(header), "Content-Length: %d\r\n", len);
    sprintf(header + strlen(header), "\r\n");

    printf("Sent Headers:\n%s", header);

    send(socket, header, strlen(header), 0);
    send(socket, buffer, len, 0);

    free(buffer);
}

Servidor Pub/Sub

En la entrada Cómo enviar una alarma a un móvil se explica cómo implementar un servidor Pub / Sub. Sino es que el servidor Pub/Sub se encuentre dentro de nuestra red local, también es importante entender la entrada Cómo autentificar un dispositivo en el servidor.

Recurso alarm - método notification/image

La implementación en Java para el framework Jersey del método método notification/image en el recurso alarm que consume multipart/form-data.


    @POST
    @SecuredDevice
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Produces(MediaType.TEXT_PLAIN)
    @Path("/notification/image")
    public Response sendNotificationImage(@Context SecurityContext securityContext,
            @FormDataParam("title") String title,
            @FormDataParam("body") String body,
            @FormDataParam("image") InputStream stream,
            @FormDataParam("image") FormDataContentDisposition fileDetail) {

        try (InputStream imageStream = stream)
        {
            GoogleCloudStorage gcs = GoogleCloudStorage.getInstance();
            String mediaLink;
            mediaLink = gcs.saveImage(fileDetail.getFileName(), imageStream);
            NotificationDomain nd = new NotificationDomain();
            nd.setBody(body);
            nd.setTitle(title);
            FirebaseMessage fb = FirebaseMessage.getInstance();
            SubscriberDomain sd = subscriberController.get();
            String registrationToken = sd.getIdToken();
            fb.sendNotification(registrationToken, nd, mediaLink);
        } catch (IOException ex)
        {
            Logger.getLogger(AlarmREST.class.getName())
                    .log(Level.SEVERE, "Error sending notification image: {0}", ex.getMessage());
            return Response.serverError().entity("Error sending notification image").build();
        }

        return Response.accepted().build();
    }

La imagen de la notificación cuando llega al móvil tiene que estar en una dirección URL, así que la guardamos en el Google Cloud Storage.


package com.elmoli.security.controller;

import static com.elmoli.security.Config.BUCKET_NAME;
import static com.elmoli.security.Config.PROJECT_ID;
import com.google.cloud.storage.Acl;
import com.google.cloud.storage.Acl.Role;
import com.google.cloud.storage.Acl.User;
import com.google.cloud.storage.Blob;
import com.google.cloud.storage.BlobId;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.StorageOptions;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;

public class GoogleCloudStorage
{
    static final List ACL_PUBLIC = Arrays.asList(Acl.of(User.ofAllUsers(), Role.READER));    
    static Storage storage = null;

    private GoogleCloudStorage()
    {
        if (storage == null)
        {
            try
            {
                storage = StorageOptions.newBuilder().setProjectId(PROJECT_ID).build().getService();
            } catch (Exception e)
            {
                throw new RuntimeException("Error initializing Google Cloud Storage", e);
            }
        }
    }

    public String saveImage(String imageName, InputStream stream) throws IOException
    {
        BlobId blobId = BlobId.of(BUCKET_NAME, imageName);
        BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setAcl(ACL_PUBLIC).build();
        Blob blob = storage.createFrom(blobInfo, stream);

        // Media Links have a query parameter that changes with the update of
        // the image that avoids cache problems
        return (blob.getMediaLink());
    }

    // Synchronization is not needed because this code relies on the class
    // loader's internal synchronization
    public static GoogleCloudStorage getInstance()
    {
        return GoogleCloudStorageHolder.INSTANCE;
    }

    // this is only initialized if the getInstance is called.
    private static class GoogleCloudStorageHolder
    {
        private static final GoogleCloudStorage INSTANCE = new GoogleCloudStorage();
    }

}

App - notificación móvil

En la entrada Cómo enviar una alarma a un móvil se explica cómo implementar la App para recibir notificaciones en un móvil. La aplicación no es más que el ejemplo Firebase Cloud Messaging Quickstart a la que se ha añadido una autentificación de usuario con Google Identity y una subscription al recurso alarm de nuestro servidor Pub/Sub.

Debajo un ejemplo de notificación/alarma recibido en nuestro móvil que al tocarla nos tendría que redirigir a una página del servidor con un muestreo a base de imágenes de los últimos movimientos detectados, mucho más fáciles de examinar que los videos de un mes que acostubran a guardar las cámaras IP.

Ejemplo de la notificación

Para finalizar

Esta entrada aglutina varias entradas de Consolidando y es la base de un proyecto real. No nos vamos a extender en cómo mejorar el host, la aplicación de la Raspberry o la app del móvil por tratarse sólo de software, sin un interés a nivel de estructurar el proyecto que es lo único que se pretendía.

En el caso de querer implementar el proyecto se aconseja seguir el orden de las entradas.

  1. Cómo autentificar un dispositivo en el servidor
  2. Cómo enviar una alarma a un móvil
  3. Controlando Corriente Alterna - Relé - Raspberry PI
  4. Detectando Corriente Alterna - Sensor de Movimiento - Raspberry PI
  5. Cómo hacer una foto con una cámara IP - FFMpeg

El código

Puedes encontrar el código de la Raspberry Pi de esta entrada en el repositorio de GitHub: Consolidando - Raspberry.


Comentarios