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)
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())