def __init__(self,
                 token: str,
                 username: str,
                 persistence_dir: Optional[str] = None,
                 log_level: Union[int, str, None] = None) -> None:
        self.logger = logging.getLogger(__name__)
        if log_level:
            self.logger.setLevel(log_level)

        if not token:
            self.logger.critical("Error! Missing BOT_API_KEY configuration")
            sys.exit(1)
        if not username:
            self.logger.critical(
                "Error! Missing AUTHORIZED_USER configuration")
            sys.exit(1)

        try:
            self.camera = Camera()
        except CameraConnectionError:
            self.logger.critical("Error! Can not connect to the camera.")
            sys.exit(2)
        except CodecNotAvailable:
            self.logger.critical(
                "Error! There are no suitable video codec available.")
            sys.exit(2)

        self.authorized_user = username

        persistence: Optional[PicklePersistence]
        if persistence_dir:
            os.makedirs(persistence_dir)
            path = os.path.join(persistence_dir, 'surveillance-bot.pickle')
            persistence = PicklePersistence(filename=path)
        else:
            persistence = None

        self.updater = Updater(token=token,
                               persistence=persistence,
                               use_context=True)

        dispatcher: Dispatcher = self.updater.dispatcher

        # Registers commands in the dispatcher
        for name, method in inspect.getmembers(self, inspect.ismethod):
            if name.startswith('_command_'):
                command = name.replace('_command_', '')
                dispatcher.add_handler(self.command_handler(command, method))

        # Registers configuration menu
        dispatcher.add_handler(BotConfig.get_config_handler(self))

        # Register error handler
        dispatcher.add_error_handler(self._error)
Exemple #2
0
def test_init_ok(mocker: pytest_mock.mocker) -> None:
    """
    Tests Camera instance construction.

    Args:
        mocker: Fixture for object mocking.
    """
    mock_video_capture(mocker, reader=False)

    Camera()
Exemple #3
0
def test_init_codec_not_available(mocker: pytest_mock.mocker) -> None:
    """
    Tests Camera instantiation when codec is not available.

    Args:
        mocker: Fixture for object mocking.
    """
    mock_video_capture(mocker, reader=False)
    mock_bad_video_writer(mocker)

    with pytest.raises(CodecNotAvailable):
        Camera()
Exemple #4
0
def test_detect_duplicated_frames(mocker: pytest_mock.mocker) -> None:
    """
    Tests duplicated frames detection.

    Frame requesting can be faster than device frame grabbing, so the same
    frame can be retrieved more than once. This test simulates a very low
    framerate and then tries to detect motion (two consecutive frames are
    different).

    Args:
        mocker: Fixture for object mocking.
    """
    mock_video_capture(mocker, fps=5)

    camera = Camera()
    camera.start()

    gen = camera.surveillance_start(video_seconds=0.1)
    assert 'detected' in next(gen)
    camera.surveillance_stop()

    camera.stop()
Exemple #5
0
def test_start_and_stop(mocker: pytest_mock.mocker) -> None:
    """
    Tests camera process starting and stopping.

    Args:
        mocker: Fixture for object mocking.
    """
    mock_video_capture(mocker, reader=False)

    camera = Camera()
    camera.start()
    camera.stop()
Exemple #6
0
def test_surveillance_mode(mocker: pytest_mock.mocker) -> None:
    """
    Tests surveillance mode process.

    Args:
        mocker: Fixture for object mocking.
    """
    mock_video_capture(mocker)

    camera = Camera()
    camera.start()
    sleep(0.5)  # Wait for fps calculation

    gen = camera.surveillance_start(video_seconds=1, picture_seconds=0.8)

    assert 'detected' in next(gen)
    assert camera.is_surveillance_active is True
    assert 'photo' in next(gen)
    assert 'video' in next(gen)
    assert 'detected' in next(gen)
    camera.surveillance_stop()
    assert camera.is_surveillance_active is False

    camera.stop()
Exemple #7
0
def test_get_photo(mocker: pytest_mock.mocker) -> None:
    """
    Tests photo taking method.

    Args:
        mocker: Fixture for object mocking.
    """
    mock_video_capture(mocker)

    camera = Camera()
    camera.start()

    # Photo without timestamp
    image = camera.get_photo(False)
    assert md5(image.read()).hexdigest() in FRAMES_MD5

    # Photo with timestamp
    image = camera.get_photo()
    assert md5(image.read()).hexdigest() not in FRAMES_MD5

    camera.stop()
Exemple #8
0
def test_get_video(mocker: pytest_mock.mocker) -> None:
    """
    Tests video taking method.

    Args:
        mocker: Fixture for object mocking.
    """
    mock_video_capture(mocker)

    camera = Camera()
    camera.start()
    sleep(0.5)  # Wait for fps calculation

    video = camera.get_video(seconds=0.5)
    # Checks MP4 magic numbers
    assert video.read(12) == b'\x00\x00\x00\x1cftypisom'

    camera.stop()
class Bot:
    """
    Class for the telegram bot implementation.

    This class exposes a number of commands to the user in order to control
    the camera processes and receive picture and video files.

    Args:
        token: Access Token for the telegram bot.
        username: Username of the only user authorized to interact with the
            bot (without @).
        log_level: Logging level for logging module.
    """
    def __init__(self,
                 token: str,
                 username: str,
                 persistence_dir: Optional[str] = None,
                 log_level: Union[int, str, None] = None) -> None:
        self.logger = logging.getLogger(__name__)
        if log_level:
            self.logger.setLevel(log_level)

        if not token:
            self.logger.critical("Error! Missing BOT_API_KEY configuration")
            sys.exit(1)
        if not username:
            self.logger.critical(
                "Error! Missing AUTHORIZED_USER configuration")
            sys.exit(1)

        try:
            self.camera = Camera()
        except CameraConnectionError:
            self.logger.critical("Error! Can not connect to the camera.")
            sys.exit(2)
        except CodecNotAvailable:
            self.logger.critical(
                "Error! There are no suitable video codec available.")
            sys.exit(2)

        self.authorized_user = username

        persistence: Optional[PicklePersistence]
        if persistence_dir:
            os.makedirs(persistence_dir)
            path = os.path.join(persistence_dir, 'surveillance-bot.pickle')
            persistence = PicklePersistence(filename=path)
        else:
            persistence = None

        self.updater = Updater(token=token,
                               persistence=persistence,
                               use_context=True)

        dispatcher: Dispatcher = self.updater.dispatcher

        # Registers commands in the dispatcher
        for name, method in inspect.getmembers(self, inspect.ismethod):
            if name.startswith('_command_'):
                command = name.replace('_command_', '')
                dispatcher.add_handler(self.command_handler(command, method))

        # Registers configuration menu
        dispatcher.add_handler(BotConfig.get_config_handler(self))

        # Register error handler
        dispatcher.add_error_handler(self._error)

    def command_handler(self, command: str,
                        callback: HandlerType) -> CommandHandler:
        """
        Decorates callback and returns a CommandHandler.

        This decorator restricts command use to the authorized user, loads
        defaults configuration options and adds debug logging.

        Args:
            command: The command this handler should listen for.
            callback: The callback function for this handler.

        Returns:
            Handler instance to handle Telegram commands.
        """
        logger = self.logger

        @wraps(callback)
        def wrapped(update: Update, context: CallbackContext) -> Any:

            # Checks if user is authorized
            if update.effective_chat.username != self.authorized_user:
                logger.warning('Unauthorized call to "%s" command by @%s',
                               command, update.effective_chat.username)
                update.message.reply_text(text="Unauthorized")
                return None

            BotConfig.ensure_defaults(context)
            logger.debug('Received "%s" command', command)
            return callback(update, context)

        return CommandHandler(command, wrapped)

    def start(self) -> None:
        """
        Starts the bot execution and waits to clean up before exit.

        After starting the camera and the bot polling it waits into a loop
        until the bot is interrupted by a signal. After that the camera
        device is released and the function ends.
        """
        self.camera.start()
        self.updater.start_polling()
        self.logger.info("Surveillance Bot started")

        self.updater.idle()

        self.camera.stop()
        self.logger.info("Surveillance Bot stopped")

    def _error(self, update: Update, context: CallbackContext) -> None:
        """
        Logs Errors caused by updates.

        Args:
            update: The update to be handled.
            context: The context object for the update.
        """
        self.logger.warning('Update "%s" caused error "%s"', update,
                            context.error)
        context.bot.send_message(
            chat_id=update.effective_chat.id,
            text="*ERROR|!* Unknown bot internal error, see server logs "
            "for more information|.".replace('|', '\\'),
            parse_mode=ParseMode.MARKDOWN_V2)

    def _command_start(self, update: Update, context: CallbackContext) -> None:
        """
        Handler for `/start` command.

        It sends a presentation to the user and calls the help command.

        Args:
            update: The update to be handled.
            context: The context object for the update.
        """
        update.message.reply_text(text="Welcome to the *Surveillance Bot*",
                                  parse_mode=ParseMode.MARKDOWN_V2)
        self._command_help(update, context)

    def _get_reply_keyboard(self,
                            is_active: Optional[bool] = None
                            ) -> ReplyKeyboardMarkup:
        """
        Generates Reply Keyboard content.

        Args:
            is_active: Overrides surveillance mode status.

        Returns:
            ReplyKeyboardMarkup instance with the menu content.

        """
        active = self.camera.is_surveillance_active \
            if is_active is None else is_active
        custom_keyboard = [[
            '/get_photo', '/get_video'
        ], ['/surveillance_{}'.format('stop' if active else 'start')]]
        return ReplyKeyboardMarkup(custom_keyboard, resize_keyboard=True)

    def _command_help(self, update: Update, _: CallbackContext) -> None:
        """
        Shows a help message listing all available commands.

        This command also sends the custom keyboard to the user.

        Args:
            update: The update to be handled.
        """
        update.message.reply_text(
            text="With this bot, photos or videos can be taken with the cam "
            "upon request|. A surveillance mode is also included|. This "
            "mode warns you when it detects movement and it will start "
            "recording a video|. Whilst recording, photos will be taken "
            "and sent periodically|.\n"
            "\n"
            "These are the available commands:\n"
            "\n"
            "*On Demand commands*\n"
            "/get|_photo |- Takes a picture from the cam\n"
            "/get|_video |- Takes a video from the cam\n"
            "\n"
            "*Surveillance Mode commands*\n"
            "/surveillance|_start |- Starts surveillance mode\n"
            "/surveillance|_stop |- Stops surveillance mode\n"
            "/surveillance|_status |- Indicates if surveillance mode "
            "is active or not\n"
            "\n"
            "*General commands*\n"
            "/config |- Invokes configuration menu\n"
            "/stop|_config |- Abort configuration sequence\n"
            "/help |- Shows this help text\n"
            "".replace('|', '\\'),
            parse_mode=ParseMode.MARKDOWN_V2,
            reply_markup=self._get_reply_keyboard())

    def _command_get_photo(self, update: Update,
                           context: CallbackContext) -> None:
        """
        Handler for `/get_photo` command.

        It takes a single shot and sends it to the user.

        Args:
            update: The update to be handled.
            context: The context object for the update.
        """
        # Retrieves configuration
        timestamp = context.bot_data[BotConfig.TIMESTAMP]

        # Uploads photo
        context.bot.send_chat_action(chat_id=update.message.chat_id,
                                     action=ChatAction.UPLOAD_PHOTO)
        context.bot.send_photo(
            chat_id=update.message.chat_id,
            photo=self.camera.get_photo(timestamp=timestamp))

    def _command_get_video(self, update: Update,
                           context: CallbackContext) -> None:
        """
        Handler for `/get_video` command.

        It takes a video and sends it to the user.

        Args:
            update: The update to be handled.
            context: The context object for the update.
        """
        # Retrieves configuration
        timestamp = context.bot_data[BotConfig.TIMESTAMP]
        seconds = context.bot_data[BotConfig.OD_VIDEO_DURATION]

        # Sends waiting message
        message = update.message.reply_text(
            text=f'Recording a {seconds} seconds video...')

        # Records video
        context.bot.send_chat_action(chat_id=update.message.chat_id,
                                     action=ChatAction.RECORD_VIDEO)
        video = self.camera.get_video(timestamp=timestamp, seconds=seconds)

        # Uploads video
        context.bot.send_chat_action(chat_id=update.message.chat_id,
                                     action=ChatAction.UPLOAD_VIDEO)
        context.bot.send_video(chat_id=update.message.chat_id, video=video)

        # Deletes waiting message
        context.bot.delete_message(chat_id=update.message.chat_id,
                                   message_id=message.message_id)

    @run_async
    def _command_surveillance_start(self, update: Update,
                                    context: CallbackContext) -> None:
        """
        Handler for `/surveillance_start` command.

        It starts the surveillance mode. In this mode the is waiting for
        motion detection, when this happens it sends a message to the user
        and start to record a video, sending pictures in regular intervals
        during the video recording. After that it goes back to the waiting
        state.

        Args:
            update: The update to be handled.
            context: The context object for the update.
        """
        # Check if surveillance is already started
        if self.camera.is_surveillance_active:
            update.message.reply_text(
                text='Error! Surveillance is already started')
            self.logger.warning("Surveillance already started")
            return

        # Retrieve configuration
        timestamp = context.bot_data[BotConfig.TIMESTAMP]
        video_seconds = context.bot_data[BotConfig.SRV_VIDEO_DURATION]
        picture_interval = context.bot_data[BotConfig.SRV_PICTURE_INTERVAL]
        motion_contours = context.bot_data[BotConfig.SRV_MOTION_CONTOURS]

        # Starts surveillance
        waiting_message = None
        self.logger.info('Surveillance mode start')
        update.message.reply_text(text="Surveillance mode started",
                                  reply_markup=self._get_reply_keyboard(True))
        for data in self.camera.surveillance_start(
                timestamp=timestamp,
                video_seconds=video_seconds,
                picture_seconds=picture_interval,
                contours=motion_contours):
            if 'detected' in data:
                update.message.reply_text(text='*MOTION DETECTED|!*'.replace(
                    '|', '\\'),
                                          parse_mode=ParseMode.MARKDOWN_V2)
                waiting_message = update.message.reply_text(
                    text=f'Recording a {video_seconds} seconds video and '
                    f'taking {video_seconds // picture_interval} '
                    f'photos...')
                context.bot.send_chat_action(chat_id=update.message.chat_id,
                                             action=ChatAction.RECORD_VIDEO)
            if 'photo' in data:
                context.bot.send_chat_action(chat_id=update.message.chat_id,
                                             action=ChatAction.UPLOAD_PHOTO)
                context.bot.send_photo(
                    chat_id=update.message.chat_id,
                    photo=data['photo'],
                    caption=f'Capture {data["id"]}/{data["total"]}')
                context.bot.send_chat_action(chat_id=update.message.chat_id,
                                             action=ChatAction.RECORD_VIDEO)
            if 'video' in data:
                context.bot.send_chat_action(chat_id=update.message.chat_id,
                                             action=ChatAction.UPLOAD_VIDEO)
                context.bot.send_video(chat_id=update.message.chat_id,
                                       video=data['video'])
                if waiting_message:
                    context.bot.delete_message(
                        chat_id=update.message.chat_id,
                        message_id=waiting_message.message_id)
                    waiting_message = None

        if waiting_message:
            context.bot.delete_message(chat_id=update.message.chat_id,
                                       message_id=waiting_message.message_id)
        update.message.reply_text(text="Surveillance mode stopped",
                                  reply_markup=self._get_reply_keyboard())
        self.logger.info('Surveillance mode stop')

    def _command_surveillance_stop(self, update: Update,
                                   _: CallbackContext) -> None:
        """
        Handler for `/surveillance_stop` command.

        This method stops the surveillance mode.

        Args:
            update: The update to be handled.
        """
        # Checks if surveillance is not running.
        if not self.camera.is_surveillance_active:
            update.message.reply_text(
                text="Error! Surveillance is not started")
            self.logger.warning("Surveillance is not started")
            return

        # Stop surveillance.
        self.camera.surveillance_stop()

    def _command_surveillance_status(self, update: Update,
                                     _: CallbackContext) -> None:
        """
        Handler for `/surveillance_stats` command.

        This method informs to the user whether surveillance mode is active
        or not.

        Args:
            update: The update to be handled.
        """
        if self.camera.is_surveillance_active:
            update.message.reply_text(text="Surveillance mode is active")
        else:
            update.message.reply_text(text="Surveillance mode is not active")