示例#1
0
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
示例#2
0
文件: remote.py 项目: dasl-/piwall2
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)
示例#3
0
文件: receiver.py 项目: dasl-/piwall2
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
示例#4
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())