Raspi_homeassitant

Since an update on my Raspberry Pi 4 to Debian Bookworm the internal RaspiCam isn’t working anymore. The Homeassistant script tries to call raspistill, which doesn’t exist and doesn’t work anymore on bullseye. But there is an alternative! The new tool is rpicam-still, which is not as powerful but it works. If you made the same decision to update to bullseye and still want to use your camera, than you can use this script as a workaround. It’s still not working like the old but you will have an image.

First check if in /boot/config.txt is camera_auto_detect=1 set.

Then replace the camera.py file. It can be anywhere, so maybe search for it and edit it as follows:

find / -type f -name "camera.py" 2>/dev/null

/srv/homeassistant/lib/python3.11/site-packages/homeassistant/components/rpi_camera/camera.py

Edit the file (check if it is the rpi_camera-folder) and replace it with this code:

"""Camera platform that has a Raspberry Pi camera."""
from __future__ import annotations

import logging
import os
import shutil
import subprocess
from tempfile import NamedTemporaryFile
from PIL import Image, ImageDraw, ImageFont, ImageStat
from datetime import datetime
from io import BytesIO

from homeassistant.components.camera import Camera
from homeassistant.const import CONF_FILE_PATH, CONF_NAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

from .const import (
    CONF_HORIZONTAL_FLIP,
    CONF_IMAGE_HEIGHT,
    CONF_IMAGE_QUALITY,
    CONF_IMAGE_ROTATION,
    CONF_IMAGE_WIDTH,
    CONF_OVERLAY_METADATA,
    CONF_OVERLAY_TIMESTAMP,
    CONF_TIMELAPSE,
    CONF_VERTICAL_FLIP,
    DOMAIN,
)

TEXT_X = 10
TEXT_Y = 10

_LOGGER = logging.getLogger(__name__)


def kill_rpicam_still(*args):
    """Kill any previously running rpicam-still process.."""
    with subprocess.Popen(
        ["killall", "rpicam-still"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.STDOUT,
        close_fds=False,  # required for posix_spawn
    ):
        pass


def setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    add_entities: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None,
) -> None:
    """Set up the Raspberry Camera."""
    # We only want this platform to be set up via discovery.
    # prevent initializing by erroneous platform config section in yaml conf
    if discovery_info is None:
        return

    if shutil.which("rpicam-still") is None:
        _LOGGER.error("'rpicam-still' was not found")
        return

    hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_rpicam_still)

    setup_config = hass.data[DOMAIN]
    file_path = setup_config[CONF_FILE_PATH]

    def delete_temp_file(*args):
        """Delete the temporary file to prevent saving multiple temp images.

        Only used when no path is defined
        """
        os.remove(file_path)

    # If no file path is defined, use a temporary file
    if file_path is None:
        with NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file:
            file_path = temp_file.name
        setup_config[CONF_FILE_PATH] = file_path
        hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, delete_temp_file)

    # Check whether the file path has been whitelisted
    elif not hass.config.is_allowed_path(file_path):
        _LOGGER.error("'%s' is not a whitelisted directory", file_path)
        return

    add_entities([RaspberryCamera(setup_config)])


class RaspberryCamera(Camera):
    """Representation of a Raspberry Pi camera."""

    def __init__(self, device_info):
        """Initialize Raspberry Pi camera component."""
        super().__init__()

        self._name = device_info[CONF_NAME]
        self._config = device_info

        # Kill if there's rpicam-still instance
        kill_rpicam_still()

        cmd_args = [
            "rpicam-still",
            "--nopreview",
            "-o",
            device_info[CONF_FILE_PATH],
            "-t",
            "0",
            "--width",
            str(device_info[CONF_IMAGE_WIDTH]),
            "--height",
            str(device_info[CONF_IMAGE_HEIGHT]),
            "--timelaps",
            str(device_info[CONF_TIMELAPSE]),
            "-q",
            str(device_info[CONF_IMAGE_QUALITY]),
#            "--rotation",
#            str(device_info[CONF_IMAGE_ROTATION]),
        ]
        if device_info[CONF_HORIZONTAL_FLIP]:
            cmd_args.append("--hflip")

        if device_info[CONF_VERTICAL_FLIP]:
            cmd_args.append("--vflip")

#        if device_info[CONF_OVERLAY_METADATA]:
#            cmd_args.append("--info-text")
#            cmd_args.append(str(device_info[CONF_OVERLAY_METADATA]))
#
#        if device_info[CONF_OVERLAY_TIMESTAMP]:
#            cmd_args.append("--info-text")
#            cmd_args.append("4")
#            cmd_args.append("--info-text")
#            cmd_args.append(str(device_info[CONF_OVERLAY_TIMESTAMP]))

        # The rpicam-still process started below must run "forever" in
        # the background until killed when Home Assistant is stopped.
        # Therefore it must not be wrapped with "with", since that
        # waits for the subprocess to exit before continuing.
        subprocess.Popen(  # pylint: disable=consider-using-with
            cmd_args,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.STDOUT,
            close_fds=False,  # required for posix_spawn
        )
    
    def camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
        """Return rpicam-still image response."""
        try:
            with open(self._config[CONF_FILE_PATH], "rb") as file:
                try:
                    original_image = Image.open(file)
                except:
                    return None
                angle_to_rotate = self._config[CONF_IMAGE_ROTATION]
                rotated_image = original_image.rotate(angle_to_rotate)
    
                # Get the width and height of the original image
                width, height = original_image.size
    
                # Calculate the crop dimensions for 16:9 aspect ratio
                crop_width = min(rotated_image.size[0], rotated_image.size[1] * 16 / 9)
                crop_height = crop_width * 9 / 16
    
                # Crop the image to 16:9 aspect ratio
                left = (rotated_image.size[0] - crop_width) / 2
                top = (rotated_image.size[1] - crop_height) / 2
                right = (rotated_image.size[0] + crop_width) / 2
                bottom = (rotated_image.size[1] + crop_height) / 2
    
                cropped_image = rotated_image.crop((left, top, right, bottom))
    
                # Analyze the top-left corner color for text contrast adjustment
                # Here we take a 10x10 pixel sample from the top left corner
                sample_area = cropped_image.crop((TEXT_X, TEXT_Y, TEXT_X+40, TEXT_Y+10))
                stat = ImageStat.Stat(sample_area)
                avg_color = stat.mean[:3]  # Get the average RGB values

                # Determine if the average color is light or dark
                brightness = sum(avg_color) / 3  # Simple brightness estimation
                text_color = (0, 0, 0) if brightness > 128 else (255, 255, 255)  # Dark text if light background, else white


                # Add timestamp
                draw = ImageDraw.Draw(cropped_image)
                
                text_position = (TEXT_X, TEXT_Y)
                font_size = int(width * 0.03)
                font = ImageFont.load_default(size=font_size)
                current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                draw.text(text_position, current_time, font=font, fill=text_color)
    
                # Save the rotated and cropped image
                img_bytes_io = BytesIO()
                cropped_image.save(img_bytes_io, format='JPEG')
                img_bytes = img_bytes_io.getvalue()
                return img_bytes
        except FileNotFoundError:
            return None
            print(f"Error: The file at {self._config[CONF_FILE_PATH]} was not found.")
		    # You can add additional error handling logic here if needed
        except Exception as e:
            return None
            print(f"An unexpected error occurred: {e}")

    @property
    def name(self):
        """Return the name of this camera."""
        return self._name

    @property
    def frame_interval(self):
        """Return the interval between frames of the stream."""
        return self._config[CONF_TIMELAPSE] / 1000

Feel free to modify the code. It adds a timestamp to the top left corner.

Categories:

Tags:

Comments are closed