class OmxplayerController: TV1_VIDEO_DBUS_NAME = 'piwall.tv1.video' TV1_LOADING_SCREEN_DBUS_NAME = 'piwall.tv1.loadingscreen' TV2_VIDEO_DBUS_NAME = 'piwall.tv2.video' TV2_LOADING_SCREEN_DBUS_NAME = 'piwall.tv2.loadingscreen' __DBUS_TIMEOUT_MS = 2000 __PARALLEL_CMD_TEMPLATE_PREFIX = ( f"parallel --will-cite --link --max-procs 0 " + # Run all jobs even if one or more failed. # Exit status: 1-100 Some of the jobs failed. The exit status gives the number of failed jobs. "--halt never ") # Ensure we don't have too many processes in flight that could overload CPU. # Need to track the limits separately because in Queue.__maybe_set_receiver_state, # we set volume and display_mode in quick succession. If we had a global limit of 1, # we'd risk that the display_mode never gets set due to throttling. __MAX_IN_FLIGHT_VOL_PROCS = 1 __MAX_IN_FLIGHT_CROP_PROCS = 1 def __init__(self): self.__logger = Logger().set_namespace(self.__class__.__name__) self.__user = getpass.getuser() self.__dbus_addr = None self.__dbus_pid = None self.__load_dbus_session_info() self.__in_flight_vol_procs = [] self.__in_flight_crop_procs = [] # gets a perceptual loudness % # returns a float in the range [0, 100] # TODO: update this to account for multiple dbus names def get_vol_pct(self): cmd = ( f"sudo -u {self.__user} DBUS_SESSION_BUS_ADDRESS={self.__dbus_addr} DBUS_SESSION_BUS_PID={self.__dbus_pid} dbus-send " + f"--print-reply=literal --session --reply-timeout={self.__DBUS_TIMEOUT_MS} " + f"--dest={self.TV1_VIDEO_DBUS_NAME} /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties.Get " + "string:'org.mpris.MediaPlayer2.Player' string:'Volume'") vol_cmd_output = None try: vol_cmd_output = (subprocess.check_output( cmd, shell=True, executable='/usr/bin/bash', stderr=subprocess.STDOUT).decode("utf-8")) except Exception: return 0 m = re.search(r"\s+double\s+(\d+(\.\d*)?)", vol_cmd_output) if m is None: return 0 vol_pct = 100 * float(m.group(1)) vol_pct = max(0, vol_pct) vol_pct = min(100, vol_pct) return vol_pct # pairs: a dict where each key is a dbus name and each value is a vol_pct. # vol_pct should be a float in the range [0, 100]. This is a perceptual loudness %. # e.g.: {'piwall.tv1.video': 99.8} def set_vol_pct(self, pairs): num_pairs = len(pairs) if num_pairs <= 0: return if self.__are_too_many_procs_in_flight(self.__in_flight_vol_procs, self.__MAX_IN_FLIGHT_VOL_PROCS): self.__logger.warning( "Too many in-flight dbus processes; bailing without setting volume." ) return vol_template = ( self.__get_dbus_cmd_template_prefix() + "org.freedesktop.DBus.Properties.Set string:'org.mpris.MediaPlayer2.Player' " + "string:'Volume' double:{1} >/dev/null 2>&1") if num_pairs == 1: dbus_name, vol_pct = list(pairs.items())[0] omx_vol_pct = self.__vol_pct_to_omx_vol_pct(vol_pct) cmd = vol_template.format(dbus_name, omx_vol_pct) else: parallel_vol_template = shlex.quote( vol_template.format('{1}', '{2}')) dbus_names = '' omx_vol_pcts = '' for dbus_name, vol_pct in pairs.items(): dbus_names += dbus_name + ' ' omx_vol_pcts += str( self.__vol_pct_to_omx_vol_pct(vol_pct)) + ' ' dbus_names = dbus_names.strip() omx_vol_pcts = omx_vol_pcts.strip() cmd = ( f"{self.__PARALLEL_CMD_TEMPLATE_PREFIX} {parallel_vol_template} ::: {dbus_names} " + f"::: {omx_vol_pcts}") self.__logger.debug(f"dbus_cmd vol_cmd: {cmd}") # Send dbus commands in non-blocking fashion so that the receiver process is free to handle other input. # Dbus can sometimes take a while to execute. Starting the subprocess takes about 3-20ms proc = subprocess.Popen(cmd, shell=True, executable='/usr/bin/bash') self.__in_flight_vol_procs.append(proc) # pairs: a dict where each key is a dbus name and each value is a list of crop coordinates # e.g.: {'piwall.tv1.video': (0, 0, 100, 100)} def set_crop(self, pairs): num_pairs = len(pairs) if num_pairs <= 0: return if self.__are_too_many_procs_in_flight( self.__in_flight_crop_procs, self.__MAX_IN_FLIGHT_CROP_PROCS): self.__logger.warning( "Too many in-flight dbus processes; bailing without setting crop." ) return crop_template = ( self.__get_dbus_cmd_template_prefix() + "org.mpris.MediaPlayer2.Player.SetVideoCropPos objpath:/not/used string:'{1}' >/dev/null 2>&1" ) if num_pairs == 1: dbus_name, crop_coords = list(pairs.items())[0] cmd = crop_template.format( dbus_name, OmxplayerController.crop_coordinate_list_to_string( crop_coords)) else: parallel_crop_template = shlex.quote( crop_template.format('{1}', '{2} {3} {4} {5}')) dbus_names = '' crop_x1s = crop_y1s = crop_x2s = crop_y2s = '' for dbus_name, crop_coords in pairs.items(): dbus_names += dbus_name + ' ' x1, y1, x2, y2 = crop_coords crop_x1s += f'{x1} ' crop_y1s += f'{y1} ' crop_x2s += f'{x2} ' crop_y2s += f'{y2} ' dbus_names = dbus_names.strip() crop_x1s = crop_x1s.strip() crop_y1s = crop_y1s.strip() crop_x2s = crop_x2s.strip() crop_y2s = crop_y2s.strip() cmd = ( f"{self.__PARALLEL_CMD_TEMPLATE_PREFIX} {parallel_crop_template} ::: {dbus_names} " + f"::: {crop_x1s} ::: {crop_y1s} ::: {crop_x2s} ::: {crop_y2s}") self.__logger.debug(f"dbus_cmd crop_cmd: {cmd}") # Send dbus commands in non-blocking fashion so that the receiver process is free to handle other input. # Dbus can sometimes take a while to execute. Starting the subprocess takes about 3-20ms proc = subprocess.Popen(cmd, shell=True, executable='/usr/bin/bash') self.__in_flight_crop_procs.append(proc) @staticmethod def crop_coordinate_list_to_string(crop_coord_list): if not crop_coord_list: return '' return ' '.join([str(e) for e in crop_coord_list]) # start playback / unpause the video def play(self, dbus_names): num_dbus_names = len(dbus_names) if num_dbus_names <= 0: return # Don't check if too many procs are in flight, because we never want to ignore a play command. # This is used to start the video playback in sync across all the TVs. play_template = (self.__get_dbus_cmd_template_prefix() + "org.mpris.MediaPlayer2.Player.Play >/dev/null 2>&1") if num_dbus_names == 1: cmd = play_template.format(dbus_names[0]) else: parallel_play_template = shlex.quote(play_template.format('{1}')) dbus_names_str = ' '.join(dbus_names.keys()) cmd = f"{self.__PARALLEL_CMD_TEMPLATE_PREFIX} {parallel_play_template} ::: {dbus_names_str} " self.__logger.debug(f"dbus_cmd play_cmd: {cmd}") # Send dbus commands in non-blocking fashion so that the receiver process is free to handle other input. # Dbus can sometimes take a while to execute. Starting the subprocess takes about 3-20ms proc = subprocess.Popen(cmd, shell=True, executable='/usr/bin/bash') # omxplayer uses a different algorithm for computing volume percentage from the original millibels than # our VolumeController class uses. Convert to omxplayer's equivalent percentage for a smoother volume # adjustment experience. def __vol_pct_to_omx_vol_pct(self, vol_pct): # See: https://github.com/popcornmix/omxplayer#volume-rw millibels = VolumeController.pct_to_millibels(vol_pct) omx_vol_pct = math.pow(10, millibels / 2000) omx_vol_pct = max(omx_vol_pct, 0) omx_vol_pct = min(omx_vol_pct, 1) return round(omx_vol_pct, 2) def __are_too_many_procs_in_flight(self, in_flight_procs, max_procs): updated_in_flight_procs = [] for proc in in_flight_procs: if proc.poll() is None: updated_in_flight_procs.append(proc) # modify in_flight_procs in place so that all references are updated in_flight_procs.clear() in_flight_procs.extend(updated_in_flight_procs) if len(in_flight_procs) >= max_procs: return True return False def __get_dbus_cmd_template_prefix(self): dbus_timeout_s = self.__DBUS_TIMEOUT_MS / 1000 + 0.1 dbus_kill_after_timeout_s = dbus_timeout_s + 0.1 dbus_prefix = ( f'sudo timeout --kill-after={dbus_kill_after_timeout_s} {dbus_timeout_s} ' 'sudo -u ' + self.__user + ' ' + 'DBUS_SESSION_BUS_ADDRESS=' + self.__dbus_addr + ' ' + 'DBUS_SESSION_BUS_PID=' + self.__dbus_pid + ' ' + 'dbus-send --print-reply=literal --session --reply-timeout=' + str(self.__DBUS_TIMEOUT_MS) + ' ' + '--dest={0} /org/mpris/MediaPlayer2 ') return dbus_prefix # Returns a boolean. True if the session was loaded or already loaded, false if we failed to load. def __load_dbus_session_info(self): if self.__dbus_addr and self.__dbus_pid: return True # already loaded dbus_addr_file_path = f"/tmp/omxplayerdbus.{self.__user}" dbus_pid_file_path = f"/tmp/omxplayerdbus.{self.__user}.pid" self.__logger.info( f"Reading dbus info from files {dbus_addr_file_path} and {dbus_pid_file_path}." ) # Omxplayer creates these files on its first run after a reboot. # These files might not yet exist if omxplayer has not been started since the pi # was last rebooted. dbus_addr_file = None try: dbus_addr_file = open(dbus_addr_file_path) except Exception: self.__logger.debug(f"Unable to open {dbus_addr_file_path}") return False dbus_pid_file = None try: dbus_pid_file = open(dbus_pid_file_path) except Exception: self.__logger.debug(f"Unable to open {dbus_pid_file_path}") return False self.__dbus_addr = dbus_addr_file.read().strip() self.__dbus_pid = dbus_pid_file.read().strip() if self.__dbus_addr and self.__dbus_pid: return True
class Remote: __VOLUME_INCREMENT = 1 __CHANNEL_VIDEOS = None # This defines the order in which we will cycle through the animation modes by pressing # the KEY_BRIGHTNESSUP / KEY_BRIGHTNESSDOWN buttons __ANIMATION_MODES = ( Animator.ANIMATION_MODE_TILE, Animator.ANIMATION_MODE_REPEAT, Animator.ANIMATION_MODE_TILE_REPEAT, Animator.ANIMATION_MODE_SPIRAL, ) def __init__(self, ticks_per_second): self.__logger = Logger().set_namespace(self.__class__.__name__) self.__ticks_per_second = ticks_per_second self.__control_message_helper = ControlMessageHelper( ).setup_for_broadcaster() self.__display_mode = DisplayMode() self.__animator = Animator() self.__vol_controller = VolumeController() self.__unmute_vol_pct = None self.__config_loader = ConfigLoader() self.__logger.info("Connecting to LIRC remote socket...") self.__socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.__socket.settimeout(1) self.__socket.connect('/var/run/lirc/lircd') self.__logger.info("Connected!") self.__channel = None self.__playlist = Playlist() self.__currently_playing_item = None if Remote.__CHANNEL_VIDEOS is None: Remote.__CHANNEL_VIDEOS = list( self.__config_loader.get_raw_config().get( 'channel_videos', {}).values()) def check_for_input_and_handle(self, currently_playing_item): start_time = time.time() self.__currently_playing_item = currently_playing_item data = b'' while True: is_ready_to_read, ignore1, ignore2 = select.select([self.__socket], [], [], 0) if not is_ready_to_read: return # The raw data will look something like (this is from holding down the volume button): # b'0000000000000490 00 KEY_VOLUMEUP RM-729A\n' # b'0000000000000490 01 KEY_VOLUMEUP RM-729A\n' # etc... # # Socket data for multiple button presses can be received in a single recv call. Unfortunately # a fixed width protocol is not used, so we have to check for the presence of the expected line # ending character (newline). raw_data = self.__socket.recv(128) data += raw_data self.__logger.debug( f"Received remote data ({len(raw_data)}): {raw_data}") if raw_data != data: self.__logger.debug(f"Using remote data ({len(data)}): {data}") data_lines = data.decode('utf-8').split('\n') num_lines = len(data_lines) full_lines = data_lines[:num_lines - 1] # If we had a "partial read" of the remote data, ensure we read the rest of the data in the # next iteration. if data_lines[num_lines - 1] == '': """ This means we read some data like: 0000000000000490 00 KEY_VOLUMEUP RM-729A\n 0000000000000490 01 KEY_VOLUMEUP RM-729A\n The lines we read ended with a newline. This means the most recent line we read was complete, so we can use it. """ data = b'' else: """ This means we read some data like: 0000000000000490 00 KEY_VOLUMEUP RM-729A\n 0000000000000490 01 KEY_VOLU The lines we read did not end with a newline. This means it was a partial read of the last line, so we can't use it. Since the last line was a partial read, don't reset the `data` variable. We'll read the remainder of the line in the next loop. """ data = data_lines[num_lines - 1].encode() # If we read data for multiple button presses in this iteration, only "use" one of those button # presses. Here we have logic to determine which one to use. `sequence` is a hex digit that increments # when you hold a button down on the remote. Use the 'first' button press (sequence == '00') whenever # possible -- for most buttons we don't do anything special when you hold the button down, aside from # the volume button. If there is no line with a sequence of '00', then use the last line. sequence = key_name = remote = None for line in full_lines: try: ignore, sequence, key_name, remote = line.split(' ') except Exception as e: self.__logger.warning( f'Got exception parsing remote data: {e}') if sequence == '00': break if not sequence: continue self.__handle_input(sequence, key_name, remote) # don't let reading remote input steal control from the main queue loop for too long if (time.time() - start_time) > ( (1 / self.__ticks_per_second) / 2): return def __handle_input(self, sequence, key_name, remote): if key_name == 'KEY_MUTE' and sequence == '00': current_vol_pct = self.__vol_controller.get_vol_pct() if current_vol_pct > 0: # mute self.__unmute_vol_pct = current_vol_pct self.__vol_controller.set_vol_pct(0) self.__control_message_helper.send_msg( ControlMessageHelper.TYPE_VOLUME, 0) else: # unmute if not self.__unmute_vol_pct: # handle case where someone manually adjusted the volume to zero. self.__unmute_vol_pct = 50 self.__vol_controller.set_vol_pct(self.__unmute_vol_pct) self.__control_message_helper.send_msg( ControlMessageHelper.TYPE_VOLUME, self.__unmute_vol_pct) self.__unmute_vol_pct = None elif (key_name in ('KEY_1', 'KEY_2', 'KEY_3', 'KEY_4', 'KEY_5', 'KEY_6', 'KEY_7', 'KEY_8', 'KEY_9', 'KEY_0') and sequence == '00'): key_num = int(key_name.split('_')[1]) tv_ids = self.__config_loader.get_tv_ids_list() tv_id = tv_ids[key_num % len(tv_ids)] self.__logger.info(f'toggle_display_mode {tv_id}') self.__display_mode.toggle_display_mode((tv_id, )) elif key_name == 'KEY_SCREEN' and sequence == '00': animation_mode = self.__animator.get_animation_mode() if animation_mode == Animator.ANIMATION_MODE_REPEAT: self.__animator.set_animation_mode( Animator.ANIMATION_MODE_TILE) else: self.__animator.set_animation_mode( Animator.ANIMATION_MODE_REPEAT) elif key_name == 'KEY_ENTER' and sequence == '00': if self.__currently_playing_item: self.__playlist.skip( self.__currently_playing_item['playlist_video_id']) elif key_name == 'KEY_VOLUMEUP': new_volume_pct = self.__vol_controller.increment_vol_pct( inc=self.__VOLUME_INCREMENT) self.__control_message_helper.send_msg( ControlMessageHelper.TYPE_VOLUME, new_volume_pct) elif key_name == 'KEY_VOLUMEDOWN': new_volume_pct = self.__vol_controller.increment_vol_pct( inc=-self.__VOLUME_INCREMENT) self.__control_message_helper.send_msg( ControlMessageHelper.TYPE_VOLUME, new_volume_pct) elif (key_name == 'KEY_CHANNELUP' or key_name == 'KEY_CHANNELDOWN') and sequence == '00': if len(Remote.__CHANNEL_VIDEOS) <= 0: return if self.__channel is None: if key_name == 'KEY_CHANNELUP': self.__channel = 0 else: self.__channel = len(Remote.__CHANNEL_VIDEOS) - 1 else: if key_name == 'KEY_CHANNELUP': self.__channel = (self.__channel + 1) % len( Remote.__CHANNEL_VIDEOS) else: self.__channel = (self.__channel - 1) % len( Remote.__CHANNEL_VIDEOS) self.__play_video_for_channel() elif (key_name == 'KEY_BRIGHTNESSUP' or key_name == 'KEY_BRIGHTNESSDOWN') and sequence == '00': old_animation_mode = self.__animator.get_animation_mode() try: old_animation_index = self.__ANIMATION_MODES.index( old_animation_mode) except Exception: # unknown animation mode, or could also be ANIMATION_MODE_NONE old_animation_index = None if old_animation_index is None: new_animation_index = 0 else: increment = 1 if key_name == 'KEY_BRIGHTNESSDOWN': increment = -1 new_animation_index = (old_animation_index + increment) % len( self.__ANIMATION_MODES) self.__animator.set_animation_mode( self.__ANIMATION_MODES[new_animation_index]) def __play_video_for_channel(self): channel_data = Remote.__CHANNEL_VIDEOS[self.__channel] video_path = DirectoryUtils( ).root_dir + '/' + channel_data['video_path'] thumbnail_path = '/' + channel_data['thumbnail_path'] """ Why is this necessary? One might think the `skip` call below would be sufficient. We could be reading multiple channel up / down button presses in here before returning control back to the queue. If we didn't skip all videos with TYPE_CHANNEL_VIDEO, then for each channel up / down button press we handle before returning control back to the queue, we'd be marking only the previously playing video as skipped. That is, we wouldn't mark the previous channel video we had just enqueued for skip. """ self.__playlist.skip_videos_of_type(Playlist.TYPE_CHANNEL_VIDEO) if self.__currently_playing_item and self.__currently_playing_item[ 'type'] != Playlist.TYPE_CHANNEL_VIDEO: self.__playlist.skip( self.__currently_playing_item['playlist_video_id']) self.__playlist.enqueue(video_path, thumbnail_path, channel_data['title'], channel_data['duration'], '', Playlist.TYPE_CHANNEL_VIDEO)
class Receiver: VIDEO_PLAYBACK_MBUFFER_SIZE_BYTES = 1024 * 1024 * 400 # 400 MB def __init__(self): self.__logger = Logger().set_namespace(self.__class__.__name__) self.__logger.info("Started receiver!") self.__hostname = socket.gethostname() + ".local" # The current crop modes for up to two TVs that may be hooked up to this receiver self.__display_mode = DisplayMode.DISPLAY_MODE_TILE self.__display_mode2 = DisplayMode.DISPLAY_MODE_TILE self.__video_crop_args = None self.__video_crop_args2 = None self.__loading_screen_crop_args = None self.__loading_screen_crop_args2 = None config_loader = ConfigLoader() self.__receiver_config_stanza = config_loader.get_own_receiver_config_stanza() self.__receiver_command_builder = ReceiverCommandBuilder(config_loader, self.__receiver_config_stanza) self.__tv_ids = self.__get_tv_ids_by_tv_num() self.__control_message_helper = ControlMessageHelper().setup_for_receiver() # Store the PGIDs separately, because attempting to get the PGID later via `os.getpgid` can # raise `ProcessLookupError: [Errno 3] No such process` if the process is no longer running self.__is_video_playback_in_progress = False self.__receive_and_play_video_proc = None self.__receive_and_play_video_proc_pgid = None self.__is_loading_screen_playback_in_progress = False self.__loading_screen_proc = None self.__loading_screen_pgid = None # house keeping # Set the video player volume to 50%, but set the hardware volume to 100%. self.__video_player_volume_pct = 50 (VolumeController()).set_vol_pct(100) self.__disable_terminal_output() self.__play_warmup_video() # This must come after the warmup video. When run as a systemd service, omxplayer wants to # start new dbus sessions / processes every time the service is restarted. This means it will # create new dbus files in /tmp when the first video is played after the service is restarted # But the old files will still be in /tmp. So if we initialize the OmxplayerController before # the new dbus files are created by playing the first video since restarting the service, we # will be reading stale dbus info from the files in /tmp. self.__omxplayer_controller = OmxplayerController() def run(self): while True: try: self.__run_internal() except Exception: self.__logger.error('Caught exception: {}'.format(traceback.format_exc())) def __run_internal(self): ctrl_msg = None ctrl_msg = self.__control_message_helper.receive_msg() # This blocks until a message is received! self.__logger.debug(f"Received control message {ctrl_msg}.") if self.__is_video_playback_in_progress: if self.__receive_and_play_video_proc and self.__receive_and_play_video_proc.poll() is not None: self.__logger.info("Ending video playback because receive_and_play_video_proc is no longer running...") self.__stop_video_playback_if_playing(stop_loading_screen_playback = True) if self.__is_loading_screen_playback_in_progress: if self.__loading_screen_proc and self.__loading_screen_proc.poll() is not None: self.__logger.info("Ending loading screen playback because loading_screen_proc is no longer running...") self.__stop_loading_screen_playback_if_playing(reset_log_uuid = False) msg_type = ctrl_msg[ControlMessageHelper.CTRL_MSG_TYPE_KEY] if msg_type == ControlMessageHelper.TYPE_INIT_VIDEO: self.__stop_video_playback_if_playing(stop_loading_screen_playback = False) self.__receive_and_play_video_proc = self.__receive_and_play_video(ctrl_msg) self.__receive_and_play_video_proc_pgid = os.getpgid(self.__receive_and_play_video_proc.pid) if msg_type == ControlMessageHelper.TYPE_PLAY_VIDEO: if self.__is_video_playback_in_progress: if self.__receiver_config_stanza['is_dual_video_output']: dbus_names = [OmxplayerController.TV1_VIDEO_DBUS_NAME, OmxplayerController.TV2_VIDEO_DBUS_NAME] else: dbus_names = [OmxplayerController.TV1_VIDEO_DBUS_NAME] self.__omxplayer_controller.play(dbus_names) elif msg_type == ControlMessageHelper.TYPE_SKIP_VIDEO: self.__stop_video_playback_if_playing(stop_loading_screen_playback = True) elif msg_type == ControlMessageHelper.TYPE_VOLUME: self.__video_player_volume_pct = ctrl_msg[ControlMessageHelper.CONTENT_KEY] vol_pairs = {} if self.__is_video_playback_in_progress: vol_pairs[OmxplayerController.TV1_VIDEO_DBUS_NAME] = self.__video_player_volume_pct if self.__receiver_config_stanza['is_dual_video_output']: vol_pairs[OmxplayerController.TV2_VIDEO_DBUS_NAME] = self.__video_player_volume_pct if self.__is_loading_screen_playback_in_progress: vol_pairs[OmxplayerController.TV1_LOADING_SCREEN_DBUS_NAME] = self.__video_player_volume_pct if self.__receiver_config_stanza['is_dual_video_output']: vol_pairs[OmxplayerController.TV2_LOADING_SCREEN_DBUS_NAME] = self.__video_player_volume_pct self.__omxplayer_controller.set_vol_pct(vol_pairs) elif msg_type == ControlMessageHelper.TYPE_DISPLAY_MODE: display_mode_by_tv_id = ctrl_msg[ControlMessageHelper.CONTENT_KEY] should_set_tv1 = False should_set_tv2 = False for tv_num, tv_id in self.__tv_ids.items(): if tv_id in display_mode_by_tv_id: display_mode_to_set = display_mode_by_tv_id[tv_id] if display_mode_to_set not in DisplayMode.DISPLAY_MODES: display_mode_to_set = DisplayMode.DISPLAY_MODE_TILE if tv_num == 1: should_set_tv1 = True self.__display_mode = display_mode_to_set else: should_set_tv2 = True self.__display_mode2 = display_mode_to_set crop_pairs = {} if self.__is_video_playback_in_progress: if should_set_tv1 and self.__video_crop_args: crop_pairs[OmxplayerController.TV1_VIDEO_DBUS_NAME] = self.__video_crop_args[self.__display_mode] if should_set_tv2 and self.__receiver_config_stanza['is_dual_video_output'] and self.__video_crop_args2: crop_pairs[OmxplayerController.TV2_VIDEO_DBUS_NAME] = self.__video_crop_args2[self.__display_mode2] if self.__is_loading_screen_playback_in_progress: if should_set_tv1 and self.__loading_screen_crop_args: crop_pairs[OmxplayerController.TV1_LOADING_SCREEN_DBUS_NAME] = self.__loading_screen_crop_args[self.__display_mode] if should_set_tv2 and self.__receiver_config_stanza['is_dual_video_output'] and self.__loading_screen_crop_args2: crop_pairs[OmxplayerController.TV2_LOADING_SCREEN_DBUS_NAME] = self.__loading_screen_crop_args2[self.__display_mode2] if crop_pairs: self.__omxplayer_controller.set_crop(crop_pairs) elif msg_type == ControlMessageHelper.TYPE_SHOW_LOADING_SCREEN: self.__loading_screen_proc = self.__show_loading_screen(ctrl_msg) self.__loading_screen_pgid = os.getpgid(self.__loading_screen_proc.pid) elif msg_type == ControlMessageHelper.TYPE_END_LOADING_SCREEN: self.__stop_loading_screen_playback_if_playing(reset_log_uuid = False) def __receive_and_play_video(self, ctrl_msg): ctrl_msg_content = ctrl_msg[ControlMessageHelper.CONTENT_KEY] Logger.set_uuid(ctrl_msg_content['log_uuid']) cmd, self.__video_crop_args, self.__video_crop_args2 = ( self.__receiver_command_builder.build_receive_and_play_video_command_and_get_crop_args( ctrl_msg_content['log_uuid'], ctrl_msg_content['video_width'], ctrl_msg_content['video_height'], self.__video_player_volume_pct, self.__display_mode, self.__display_mode2 ) ) self.__logger.info(f"Running receive_and_play_video command: {cmd}") self.__is_video_playback_in_progress = True proc = subprocess.Popen( cmd, shell = True, executable = '/usr/bin/bash', start_new_session = True ) return proc def __show_loading_screen(self, ctrl_msg): ctrl_msg_content = ctrl_msg[ControlMessageHelper.CONTENT_KEY] Logger.set_uuid(ctrl_msg_content['log_uuid']) cmd, self.__loading_screen_crop_args, self.__loading_screen_crop_args2 = ( self.__receiver_command_builder.build_loading_screen_command_and_get_crop_args( self.__video_player_volume_pct, self.__display_mode, self.__display_mode2, ctrl_msg_content['loading_screen_data'] ) ) self.__logger.info(f"Showing loading screen with command: {cmd}") self.__is_loading_screen_playback_in_progress = True proc = subprocess.Popen( cmd, shell = True, executable = '/usr/bin/bash', start_new_session = True ) return proc def __stop_video_playback_if_playing(self, stop_loading_screen_playback): if stop_loading_screen_playback: self.__stop_loading_screen_playback_if_playing(reset_log_uuid = False) if not self.__is_video_playback_in_progress: if stop_loading_screen_playback: Logger.set_uuid('') return if self.__receive_and_play_video_proc_pgid: self.__logger.info("Killing receive_and_play_video proc (if it's still running)...") try: os.killpg(self.__receive_and_play_video_proc_pgid, signal.SIGTERM) except Exception: # might raise: `ProcessLookupError: [Errno 3] No such process` pass Logger.set_uuid('') self.__is_video_playback_in_progress = False self.__video_crop_args = None self.__video_crop_args2 = None def __stop_loading_screen_playback_if_playing(self, reset_log_uuid): if not self.__is_loading_screen_playback_in_progress: return if self.__loading_screen_pgid: self.__logger.info("Killing loading_screen proc (if it's still running)...") try: os.killpg(self.__loading_screen_pgid, signal.SIGTERM) except Exception: # might raise: `ProcessLookupError: [Errno 3] No such process` pass if reset_log_uuid: Logger.set_uuid('') self.__is_loading_screen_playback_in_progress = False self.__loading_screen_crop_args = None self.__loading_screen_crop_args2 = None # The first video that is played after a system restart appears to have a lag in starting, # which can affect video synchronization across the receivers. Ensure we have played at # least one video since system startup. This is a short, one-second video. # # Perhaps one thing this warmup video does is start the various dbus processes for the first # time, which can involve some sleeps: # https://github.com/popcornmix/omxplayer/blob/1f1d0ccd65d3a1caa86dc79d2863a8f067c8e3f8/omxplayer#L50-L59 # # When the receiver is run as as a systemd service, the first time a video is played after the service # is restarted, it seems that omxplayer must initialize dbus. Thus, it is important to run the warmup # video whenever the service is restarted. # # This is as opposed to when running the service as a regular user / process -- the dbus stuff stays # initialized until the pi is rebooted. def __play_warmup_video(self): self.__logger.info("Playing receiver warmup video...") warmup_cmd = f'omxplayer --vol 0 {DirectoryUtils().root_dir}/utils/short_black_screen.ts' proc = subprocess.Popen( warmup_cmd, shell = True, executable = '/usr/bin/bash' ) while proc.poll() is None: time.sleep(0.1) if proc.returncode != 0: raise Exception(f"The process for cmd: [{warmup_cmd}] exited non-zero: " + f"{proc.returncode}.") # Display a black image so that terminal text output doesn't show up on the TVs in between videos. # Basically this black image will be on display all the time "underneath" any videos that are playing. def __disable_terminal_output(self): subprocess.check_output( f"sudo fbi -T 1 --noverbose --autozoom {DirectoryUtils().root_dir}/assets/black_screen.jpg", shell = True, executable = '/usr/bin/bash', stderr = subprocess.STDOUT ) atexit.register(self.__enable_terminal_output) def __enable_terminal_output(self): try: subprocess.check_output( "sudo pkill fbi", shell = True, executable = '/usr/bin/bash', stderr = subprocess.STDOUT ) except Exception: pass # Get the tv_ids for this receiver def __get_tv_ids_by_tv_num(self): tv_ids = { 1: Tv(self.__hostname, 1).tv_id } if self.__receiver_config_stanza['is_dual_video_output']: tv_ids[2] = Tv(self.__hostname, 2).tv_id return tv_ids
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())