예제 #1
0
class ReceiverCommandBuilder:

    # See: https://github.com/dasl-/piwall2/blob/main/docs/configuring_omxplayer.adoc
    __OMX_CMD_TEMPLATE = ('omxplayer --crop {0} --adev {1} --display {2} --vol {3} --dbus_name {4} --layer {5} ' +
            '--aspect-mode stretch --no-keys --timeout 30 --threshold 0.2 --video_fifo 10 pipe:0')

    def __init__(self, config_loader, receiver_config_stanza):
        self.__logger = Logger().set_namespace(self.__class__.__name__)
        self.__config_loader = config_loader
        self.__receiver_config_stanza = receiver_config_stanza

    def build_receive_and_play_video_command_and_get_crop_args(
        self, log_uuid, video_width, video_height, volume_pct, display_mode, display_mode2
    ):
        adev, adev2 = self.__get_video_command_adev_args()
        display, display2 = self.__get_video_command_display_args()
        crop_args, crop_args2 = self.__get_video_command_crop_args(video_width, video_height)
        crop = OmxplayerController.crop_coordinate_list_to_string(crop_args[display_mode])
        crop2 = OmxplayerController.crop_coordinate_list_to_string(crop_args2[display_mode2])
        volume_millibels = self.__get_video_command_volume_arg(volume_pct)

        """
        We use mbuffer in the receiver command. The mbuffer is here to solve two problems:

        1) Sometimes the python receiver process would get blocked writing directly to omxplayer. When this happens,
        the receiver's writes would occur rather slowly. While the receiver is blocked on writing, it cannot read
        incoming data from the UDP socket. The kernel's UDP buffers would then fill up, causing UDP packets to be
        dropped.

        Unlike python, mbuffer is multithreaded, meaning it can read and write simultaneously in two separate
        threads. Thus, while mbuffer is writing to omxplayer, it can still read the incoming data from python at
        full speed. Slow writes will not block reads.

        2) I am not sure how exactly omxplayer's various buffers work. There are many options:

            % omxplayer --help
            ...
             --audio_fifo  n         Size of audio output fifo in seconds
             --video_fifo  n         Size of video output fifo in MB
             --audio_queue n         Size of audio input queue in MB
             --video_queue n         Size of video input queue in MB
            ...

        More info: https://github.com/popcornmix/omxplayer/issues/256#issuecomment-57907940

        I am not sure which I would need to adjust to ensure enough buffering is available. By using mbuffer,
        we effectively have a single buffer that accounts for any possible source of delays, whether it's from audio,
        video, and no matter where in the pipeline the delay is coming from. Using mbuffer seems simpler, and it is
        easier to monitor. By checking its logs, we can see how close the mbuffer gets to becoming full.
        """
        mbuffer_cmd = ('mbuffer -q -l /tmp/mbuffer.out -m ' +
            f'{piwall2.receiver.receiver.Receiver.VIDEO_PLAYBACK_MBUFFER_SIZE_BYTES}b')

        omx_cmd_tempate = self.__OMX_CMD_TEMPLATE + ' --start-paused'
        omx_cmd = omx_cmd_tempate.format(
            shlex.quote(crop), shlex.quote(adev), shlex.quote(display), shlex.quote(str(volume_millibels)),
            OmxplayerController.TV1_VIDEO_DBUS_NAME, '1'
        )
        cmd = 'set -o pipefail && export SHELLOPTS && '
        if self.__receiver_config_stanza['is_dual_video_output']:
            omx_cmd2 = omx_cmd_tempate.format(
                shlex.quote(crop2), shlex.quote(adev2), shlex.quote(display2), shlex.quote(str(volume_millibels)),
                OmxplayerController.TV2_VIDEO_DBUS_NAME, '1'
            )
            cmd += f'{mbuffer_cmd} | tee >({omx_cmd}) >({omx_cmd2}) >/dev/null'
        else:
            cmd += f'{mbuffer_cmd} | {omx_cmd}'

        receiver_cmd = (f'{DirectoryUtils().root_dir}/bin/receive_and_play_video --command {shlex.quote(cmd)} ' +
            f'--log-uuid {shlex.quote(log_uuid)}')
        return (receiver_cmd, crop_args, crop_args2)

    def build_loading_screen_command_and_get_crop_args(
        self, volume_pct, display_mode, display_mode2, loading_screen_data
    ):
        video_path = DirectoryUtils().root_dir + '/' + loading_screen_data['video_path']
        adev, adev2 = self.__get_video_command_adev_args()
        display, display2 = self.__get_video_command_display_args()
        crop_args, crop_args2 = self.__get_video_command_crop_args(loading_screen_data['width'], loading_screen_data['height'])
        crop = OmxplayerController.crop_coordinate_list_to_string(crop_args[display_mode])
        crop2 = OmxplayerController.crop_coordinate_list_to_string(crop_args2[display_mode2])
        volume_millibels = self.__get_video_command_volume_arg(volume_pct)

        omx_cmd = self.__OMX_CMD_TEMPLATE.format(
            shlex.quote(crop), shlex.quote(adev), shlex.quote(display), shlex.quote(str(volume_millibels)),
            OmxplayerController.TV1_LOADING_SCREEN_DBUS_NAME, '0'
        )
        cat_cmd = f'cat {video_path}'
        loading_screen_cmd = 'set -o pipefail && export SHELLOPTS && '
        if self.__receiver_config_stanza['is_dual_video_output']:
            omx_cmd2 = self.__OMX_CMD_TEMPLATE.format(
                shlex.quote(crop2), shlex.quote(adev2), shlex.quote(display2), shlex.quote(str(volume_millibels)),
                OmxplayerController.TV2_LOADING_SCREEN_DBUS_NAME, '1'
            )
            loading_screen_cmd += f'{cat_cmd} | tee >({omx_cmd}) >({omx_cmd2}) >/dev/null'
        else:
            loading_screen_cmd += f'{cat_cmd} | {omx_cmd}'

        return (loading_screen_cmd, crop_args, crop_args2)

    def __get_video_command_adev_args(self):
        receiver_config = self.__receiver_config_stanza
        adev = None
        if receiver_config['audio'] == 'hdmi' or receiver_config['audio'] == 'hdmi0':
            adev = 'hdmi'
        elif receiver_config['audio'] == 'headphone':
            adev = 'local'
        elif receiver_config['audio'] == 'hdmi_alsa' or receiver_config['audio'] == 'hdmi0_alsa':
            adev = 'alsa:default:CARD=b1'
        else:
            raise Exception(f"Unexpected audio config value: {receiver_config['audio']}")

        adev2 = None
        if receiver_config['is_dual_video_output']:
            if receiver_config['audio2'] == 'hdmi1':
                adev2 = 'hdmi1'
            elif receiver_config['audio2'] == 'headphone':
                adev2 = 'local'
            elif receiver_config['audio'] == 'hdmi1_alsa':
                adev2 = 'alsa:default:CARD=b2'
            else:
                raise Exception(f"Unexpected audio2 config value: {receiver_config['audio2']}")

        return (adev, adev2)

    def __get_video_command_display_args(self):
        receiver_config = self.__receiver_config_stanza
        display = None
        if receiver_config['video'] == 'hdmi' or receiver_config['video'] == 'hdmi0':
            display = '2'
        elif receiver_config['video'] == 'composite':
            display = '3'
        else:
            raise Exception(f"Unexpected video config value: {receiver_config['video']}")

        display2 = None
        if receiver_config['is_dual_video_output']:
            if receiver_config['video2'] == 'hdmi1':
                display2 = '7'
            else:
                raise Exception(f"Unexpected video2 config value: {receiver_config['video2']}")

        return (display, display2)

    """
    Returns a set of crop args supporting two display modes: tile mode and repeat mode.
    Tile mode is like this: https://i.imgur.com/BBrA1Cr.png
    Repeat mode is like this: https://i.imgur.com/cpS61s8.png

    We return four crop settings because for each mode, we calculate the crop arguments
    for each of two TVs (each receiver can have at most two TVs hooked up to it).
    """
    def __get_video_command_crop_args(self, video_width, video_height):
        receiver_config = self.__receiver_config_stanza

        #####################################################################################
        # Do tile mode calculations first ###################################################
        #####################################################################################
        wall_width = self.__config_loader.get_wall_width()
        wall_height = self.__config_loader.get_wall_height()

        displayable_video_width, displayable_video_height = (
            self.__get_displayable_video_dimensions_for_screen(
                video_width, video_height, wall_width, wall_height
            )
        )
        x_offset = (video_width - displayable_video_width) / 2
        y_offset = (video_height - displayable_video_height) / 2

        x0 = round(x_offset + ((receiver_config['x'] / wall_width) * displayable_video_width))
        y0 = round(y_offset + ((receiver_config['y'] / wall_height) * displayable_video_height))
        x1 = round(x_offset + (((receiver_config['x'] + receiver_config['width']) / wall_width) * displayable_video_width))
        y1 = round(y_offset + (((receiver_config['y'] + receiver_config['height']) / wall_height) * displayable_video_height))

        if x0 > video_width:
            self.__logger.warn(f"The crop x0 coordinate ({x0}) " +
                f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
        if x1 > video_width:
            self.__logger.warn(f"The crop x1 coordinate ({x1}) " +
                f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
        if y0 > video_height:
            self.__logger.warn(f"The crop y0 coordinate ({y0}) " +
                f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")
        if y1 > video_height:
            self.__logger.warn(f"The crop y1 coordinate ({y1}) " +
                f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")

        tile_mode_crop = (x0, y0, x1, y1)

        tile_mode_crop2 = None
        if receiver_config['is_dual_video_output']:
            x0_2 = round(x_offset + ((receiver_config['x2'] / wall_width) * displayable_video_width))
            y0_2 = round(y_offset + ((receiver_config['y2'] / wall_height) * displayable_video_height))
            x1_2 = round(x_offset + (((receiver_config['x2'] + receiver_config['width2']) / wall_width) * displayable_video_width))
            y1_2 = round(y_offset + (((receiver_config['y2'] + receiver_config['height2']) / wall_height) * displayable_video_height))

            if x0_2 > video_width:
                self.__logger.warn(f"The crop x0_2 coordinate ({x0_2}) " +
                    f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
            if x1_2 > video_width:
                self.__logger.warn(f"The crop x1_2 coordinate ({x1_2}) " +
                    f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
            if y0_2 > video_height:
                self.__logger.warn(f"The crop y0_2 coordinate ({y0_2}) " +
                    f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")
            if y1_2 > video_height:
                self.__logger.warn(f"The crop y1_2 coordinate ({y1_2}) " +
                    f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")

            tile_mode_crop2 = (x0_2, y0_2, x1_2, y1_2)

        #####################################################################################
        # Do repeat mode calculations second ################################################
        #####################################################################################
        displayable_video_width, displayable_video_height = (
            self.__get_displayable_video_dimensions_for_screen(
                video_width, video_height, receiver_config['width'], receiver_config['height']
            )
        )
        x_offset = (video_width - displayable_video_width) / 2
        y_offset = (video_height - displayable_video_height) / 2
        x0 = round(x_offset)
        y0 = round(y_offset)
        x1 = round(x_offset + displayable_video_width)
        y1 = round(y_offset + displayable_video_height)
        repeat_mode_crop = (x0, y0, x1, y1)

        repeat_mode_crop2 = None
        if receiver_config['is_dual_video_output']:
            displayable_video_width, displayable_video_height = (
                self.__get_displayable_video_dimensions_for_screen(
                    video_width, video_height, receiver_config['width2'], receiver_config['height2']
                )
            )
            x_offset = (video_width - displayable_video_width) / 2
            y_offset = (video_height - displayable_video_height) / 2
            x0 = round(x_offset)
            y0 = round(y_offset)
            x1 = round(x_offset + displayable_video_width)
            y1 = round(y_offset + displayable_video_height)
            repeat_mode_crop2 = (x0, y0, x1, y1)

        crop_args = {
            DisplayMode.DISPLAY_MODE_TILE: tile_mode_crop,
            DisplayMode.DISPLAY_MODE_REPEAT: repeat_mode_crop,
        }
        crop_args2 = {
            DisplayMode.DISPLAY_MODE_TILE: tile_mode_crop2,
            DisplayMode.DISPLAY_MODE_REPEAT: repeat_mode_crop2,
        }
        return (crop_args, crop_args2)

    def __get_video_command_volume_arg(self, volume_pct):
        # See: https://github.com/popcornmix/omxplayer/#volume-rw
        volume_pct = VolumeController.normalize_vol_pct(volume_pct)
        if volume_pct == 0:
            volume_millibels = VolumeController.GLOBAL_MIN_VOL_VAL
        else:
            volume_millibels = 2000 * math.log(volume_pct, 10)
        return volume_millibels

    """
    The displayable width and height represents the section of the video that the wall will be
    displaying. A section of these dimensions will be taken from the center of the original
    video.

    Currently, the piwall only supports displaying videos in "fill" mode (as opposed to
    "letterbox" or "stretch"). This means that every portion of the TVs will be displaying
    some section of the video (i.e. there will be no letterboxing). Furthermore, there will be
    no warping of the video's aspect ratio. Instead, regions of the original video will be
    cropped or stretched if necessary.

    The units of the width and height arguments are not important. We are just concerned with
    the aspect ratio. Thus, as long as video_width and video_height are in the same units
    (probably pixels), and as long as screen_width and screen_height are in the same units
    (probably inches or centimeters), everything will work.

    The returned dimensions will be in the units of the inputted video_width and video_height
    (probably pixels).
    """
    def __get_displayable_video_dimensions_for_screen(self, video_width, video_height, screen_width, screen_height):
        video_aspect_ratio = video_width / video_height
        screen_aspect_ratio = screen_width / screen_height
        if screen_aspect_ratio >= video_aspect_ratio:
            displayable_video_width = video_width
            displayable_video_height = video_width / screen_aspect_ratio
            """
            Note that `video_width = video_aspect_ratio * video_height`.
            Thus, via substitution, we have:
                displayable_video_height = (video_aspect_ratio * video_height) / screen_aspect_ratio
                displayable_video_height = (video_aspect_ratio / screen_aspect_ratio) * video_height

            And because of the above inequality, we know that:
                (video_aspect_ratio / screen_aspect_ratio) <= 1

            Thus, in this case, we have: `displayable_video_height <= video_height`. The video height
            will be "cropped" such that when the video is proportionally stretched, it will fill the
            screen size.
            """

        else:
            displayable_video_height = video_height
            displayable_video_width = screen_aspect_ratio * video_height
            """
            Note that `video_height = video_width / video_aspect_ratio`.
            Thus, via substitution, we have:
                displayable_video_width = screen_aspect_ratio * (video_width / video_aspect_ratio)
                displayable_video_width = video_width * (screen_aspect_ratio / video_aspect_ratio)

            And because of the above inequality for which we are now in the `else` clause, we know that:
                (screen_aspect_ratio / video_aspect_ratio) <= 1

            Thus, in this case, we have: `displayable_video_width <= video_width`. The video width
            will be "cropped" such that when the video is proportionally stretched, it will fill the
            screen size.
            """

        if displayable_video_width > video_width:
            self.__logger.warn(f"The displayable_video_width ({displayable_video_width}) " +
                f"was greater than the video_width ({video_width}). This may indicate a misconfiguration.")
        if displayable_video_height > video_height:
            self.__logger.warn(f"The displayable_video_height ({displayable_video_height}) " +
                f"was greater than the video_height ({video_height}). This may indicate a misconfiguration.")

        return (displayable_video_width, displayable_video_height)
예제 #2
0
class MulticastHelper:

    ADDRESS = '239.0.1.23'

    # Messages will be sent 'raw' over the video stream port
    VIDEO_PORT = 1234

    # Message will be sent according to the control protocol over the control port.
    # E.g. volume control commands.
    CONTROL_PORT = 1236

    # 2 MB. This will be doubled to 4MB when we set it via setsockopt.
    __VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES = 2097152

    # regarding socket.IP_MULTICAST_TTL
    # ---------------------------------
    # for all packets sent, after two hops on the network the packet will not
    # be re-sent/broadcast (see https://www.tldp.org/HOWTO/Multicast-HOWTO-6.html)
    __TTL = 1

    # max UDP packet size is 65535 bytes
    # IP Header is 20 bytes, UDP header is 8 bytes
    # 65535 - 20 - 8 = 65507
    # Sending a message of any larger will result in: `OSError: [Errno 90] Message too long`
    __MAX_MSG_SIZE = 65507

    __send_socket = None

    def __init__(self):
        self.__logger = Logger().set_namespace(self.__class__.__name__)

    def setup_broadcaster_socket(self):
        # Multiple classes in the broadcaster code will send messages over the socket. By using a static
        # __send_socket variable, ensure no matter how many instances of this class are created, all of
        # them use the same send socket.
        if MulticastHelper.__send_socket is None:
            MulticastHelper.__send_socket = socket.socket(
                socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
            MulticastHelper.__send_socket.setsockopt(socket.IPPROTO_IP,
                                                     socket.IP_MULTICAST_TTL,
                                                     self.__TTL)
            MulticastHelper.__send_socket.setsockopt(socket.IPPROTO_IP,
                                                     socket.IP_MULTICAST_LOOP,
                                                     0)
        return self

    def setup_receiver_video_socket(self):
        self.__setup_socket_receive_buffer_configuration()

        self.__receive_video_socket = self.__make_receive_socket(
            self.ADDRESS, self.VIDEO_PORT)

        # set a higher timeout while we wait for the first packet of the video to be sent
        self.__receive_video_socket.settimeout(60)

        # set a higher receive buffer size to avoid UDP packet loss.
        # see: https://github.com/dasl-/piwall2/blob/main/docs/issues_weve_seen_before.adoc#udp-packet-loss
        self.__receive_video_socket.setsockopt(
            socket.SOL_SOCKET, socket.SO_RCVBUF,
            self.__VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES)

        self.__logger.debug("Using receive buffer size of " + str(
            self.__receive_video_socket.getsockopt(socket.SOL_SOCKET,
                                                   socket.SO_RCVBUF)) +
                            " bytes on receiver video socket.")
        return self

    def setup_receiver_control_socket(self):
        self.__setup_socket_receive_buffer_configuration()
        self.__receive_control_socket = self.__make_receive_socket(
            self.ADDRESS, self.CONTROL_PORT)
        return self

    def send(self, msg, port):
        if port == self.VIDEO_PORT:
            self.__logger.debug(f"Sending video stream message: {msg}")
        elif port == self.CONTROL_PORT:
            self.__logger.debug(f"Sending control message: {msg}")

        address_tuple = (self.ADDRESS, port)
        msg_remainder = msg
        bytes_sent = 0
        while msg_remainder:  # Don't send more than __MAX_MSG_SIZE at a time
            msg_part = msg_remainder[:self.__MAX_MSG_SIZE]
            msg_remainder = msg_remainder[self.__MAX_MSG_SIZE:]
            bytes_sent += MulticastHelper.__send_socket.sendto(
                msg_part, address_tuple)
        if bytes_sent == 0:
            self.__logger.warn(
                f"Unable to send message. Address: {address_tuple}. Message: {msg}"
            )
        elif bytes_sent != len(msg):
            # Not sure if this can ever happen... This post suggests you cannot have partial sends in UDP:
            # https://www.gamedev.net/forums/topic/504256-partial-sendto/4289205/
            self.__logger.warn(
                f"Partial send of message. Sent {bytes_sent} of {len(msg)} bytes. "
                + f"Address: {address_tuple}. Message: {msg}")
        return bytes_sent

    """
    UDP datagram messages cannot be split. One send corresponds to one receive. Having multiple senders
    to the same socket in multiple processes will not clobber each other. Message boundaries will be
    preserved, even when sending a message that is larger than the MTU and making use of the OS's
    UDP fragmentation. The message will be reconstructed fully in the UDP stack layer.
    See: https://stackoverflow.com/questions/8748711/udp-recv-recvfrom-multiple-senders

    Use a receive buffer of the maximum packet size. Since we may be receiving messages of unknown
    lengths, this guarantees that we will not accidentally truncate any messages by using a receiver
    buffer that was too small.
    See `MSG_TRUNC` flag: https://man7.org/linux/man-pages/man2/recv.2.html
    See: https://stackoverflow.com/a/2862176/627663
    """

    def receive(self, port):
        if port == self.VIDEO_PORT:
            return self.__receive_video_socket.recv(self.__MAX_MSG_SIZE)
        elif port == self.CONTROL_PORT:
            return self.__receive_control_socket.recv(self.__MAX_MSG_SIZE)
        else:
            raise Exception(f'Unexpected port: {port}.')

    def get_receive_video_socket(self):
        return self.__receive_video_socket

    def __make_receive_socket(self, address, port):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
                             socket.IPPROTO_UDP)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((address, port))
        mreq = struct.pack("4sl", socket.inet_aton(address), socket.INADDR_ANY)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
        return sock

    # allow a higher receive buffer size to avoid UDP packet loss.
    # see: https://github.com/dasl-/piwall2/blob/main/docs/issues_weve_seen_before.adoc#udp-packet-loss
    def __setup_socket_receive_buffer_configuration(self):
        max_socket_receive_buffer_size = (subprocess.check_output(
            "sudo sysctl --values net.core.rmem_max",
            shell=True,
            executable='/usr/bin/bash',
            stderr=subprocess.STDOUT))
        max_socket_receive_buffer_size = int(
            max_socket_receive_buffer_size.decode().strip())
        if max_socket_receive_buffer_size < self.__VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES:
            output = (subprocess.check_output(
                f"sudo sysctl --write net.core.rmem_max={self.__VIDEO_SOCKET_RECEIVE_BUFFER_SIZE_BYTES}",
                shell=True,
                executable='/usr/bin/bash',
                stderr=subprocess.STDOUT).decode().strip())