# -*- coding: utf-8 -*- ########################################################################## # OpenLP - Open Source Lyrics Projection # # ---------------------------------------------------------------------- # # Copyright (c) 2008-2023 OpenLP Developers # # ---------------------------------------------------------------------- # # This program is free software: you can redistribute it and/or modify # # it under the terms of the GNU General Public License as published by # # the Free Software Foundation, either version 3 of the License, or # # (at your option) any later version. # # # # This program is distributed in the hope that it will be useful, # # but WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # # GNU General Public License for more details. # # # # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # ########################################################################## """ The :mod:`openlp.core.lib.projector.pjlinkcmmands` module provides the necessary functions for processing projector replies. NOTE: PJLink Class (version) checks are handled in the respective PJLink/PJLinkUDP classes. process_clss is the only exception. NOTE: Some commands are both commannd replies as well as UDP terminal-initiated status messages. Ex: POWR CLSS1 (TCP): controller sends "POWR x", projector replies "POWR=xxxxx" CLSS2 (UDP): projector sends "POWER=xxxx" Inn both instances, the messagege is processed the same. For CLSS1, we initiate communication, so we know which projecttor instance the message is routed to. For CLSS2, the terminal initiates communication, so as part of the UDP process we must find the projector that initiated the message. """ import logging import re import string from openlp.core.common.registry import Registry from openlp.core.projectors.constants import E_AUTHENTICATION, PJLINK_DEFAULT_CODES, PJLINK_ERRORS, \ PJLINK_ERST_DATA, PJLINK_ERST_LIST, PJLINK_ERST_STATUS, PJLINK_POWR_STATUS, PJLINK_SVER_MAX_LEN, \ PJLINK_TOKEN_SIZE, E_NO_AUTHENTICATION, S_AUTHENTICATE, S_CONNECT, S_DATA_OK, S_OFF, S_OK, S_ON, \ S_STANDBY, STATUS_MSG log = logging.getLogger(__name__) log.debug('Loading pjlinkcommands') __all__ = ['process_command'] _pjlink_functions = {} # Helper until I update the rest of the tests pjlink_functions = _pjlink_functions # This should be the only function that's imported. def process_command(projector, cmd, data): """ Verifies any return error code. Calls the appropriate command handler. :param projector: Projector instance :param cmd: Command to process :param data: Data being processed """ log.debug(f'({projector.entry.name}) Processing command "{cmd}" with data "{data}"') # cmd should already be in uppercase, but data may be in mixed-case. # Due to some replies should stay as mixed-case, validate using separate uppercase check _data = data.upper() # Check if we have a future command not available yet if cmd not in pjlink_functions: log.warning(f'({projector.entry.name}) Unable to process command="{cmd}" (Future option?)') return elif _data == 'OK': log.debug(f'({projector.entry.name}) Command "{cmd}" returned OK') # A command returned successfully, so do a query on command to verify status return S_DATA_OK elif _data in PJLINK_ERRORS: # Oops - projector error log.error(f'({projector.entry.name}) {cmd}: {STATUS_MSG[PJLINK_ERRORS[_data]]}') return PJLINK_ERRORS[_data] # Command checks already passed log.debug(f'({projector.entry.name}) Calling function for {cmd}') return pjlink_functions[cmd](projector=projector, data=data) def process_ackn(projector, data): """ Process the ACKN command. UDP reply to SRCH command :param projector: Projector instance :param data: Data in packet """ # TODO: Have to rethink this one pass _pjlink_functions['ACKN'] = process_ackn def _process_avmt_mute(projector, data): """ Helper to set projector.mute """ projector.mute = data def _process_avmt_shutter(projector, data): """ Helper to set projector.shutter """ projector.shutter = data def _process_avmt(projector, data): """ Process shutter and speaker status. See PJLink specification for format. Update projector.mute (audio mute) and projector.shutter (video mute). 10 = Shutter open, audio unchanged 11 = Shutter closed, audio unchanged 20 = Shutter unchanged, audio normal 21 = Shutter unchanged, audio mute 30 = Shutter open, audio normal 31 = Shutter closed, audio mute :param projector: Projector instance :param data: Shutter and audio status """ settings = {'10': {'shutter': False, 'mute': projector.mute}, '11': {'shutter': True, 'mute': projector.mute}, '20': {'shutter': projector.shutter, 'mute': False}, '21': {'shutter': projector.shutter, 'mute': True}, '30': {'shutter': False, 'mute': False}, '31': {'shutter': True, 'mute': True} } if data not in settings: log.warning(f'({projector.entry.name}) Invalid av mute response: {data}') return shutter = settings[data]['shutter'] mute = settings[data]['mute'] update_icons = False if projector.shutter != shutter: _process_avmt_shutter(projector=projector, data=shutter) update_icons = True log.debug(f'({projector.entry.name}) Setting shutter to {"closed" if shutter else "open"}') if projector.mute != mute: _process_avmt_mute(projector=projector, data=mute) projector.mute = mute update_icons = True log.debug(f'({projector.entry.name}) Setting speaker to {"muted" if mute else "normal"}') if update_icons: projector.projectorUpdateIcons.emit() projector.status_timer_delete('AVMT') _pjlink_functions['AVMT'] = _process_avmt def process_clss(projector, data): """ PJLink class that this projector supports. See PJLink specification for format. Updates projector.class. :param projector: Projector instance :param data: Class that projector supports. """ # bug 1550891: Projector returns non-standard class response: # : Expected: '%1CLSS=1' # : Received: '%1CLSS=Class 1' (Optoma) # : Received: '%1CLSS=Version1' (BenQ) if len(data) > 1: log.warning(f'({projector.entry.name}) Non-standard CLSS reply: "{data}"') # Due to stupid projectors not following standards (Optoma, BenQ comes to mind), # AND the different responses that can be received, the semi-permanent way to # fix the class reply is to just remove all non-digit characters. chk = re.findall(r'\d', data) if len(chk) < 1: log.warning(f'({projector.entry.name}) No numbers found in class version reply ' f'"{data}" - defaulting to class "1"') clss = '1' else: clss = chk[0] # Should only be the first match elif not data.isdigit(): log.warning(f'({projector.entry.name}) NAN CLSS version reply ' f'"{data}" - defaulting to class "1"') clss = '1' else: clss = data projector.pjlink_class = clss log.debug(f'({projector.entry.name}) Setting pjlink_class for this projector to "{projector.pjlink_class}"') if not projector.no_poll: # Since we call this one on first connect, setup polling from here log.debug(f'({projector.entry.name}) process_pjlink(): Starting timer') projector.poll_timer.setInterval(1000) # Set 1 second for initial information projector.poll_timer.start() _pjlink_functions['CLSS'] = process_clss def process_erst(projector, data): """ Error status. See PJLink Specifications for format. Updates projector.projector_errors :param projector: Projector instance :param data: Error status """ if len(data) != PJLINK_ERST_DATA['DATA_LENGTH']: count = PJLINK_ERST_DATA['DATA_LENGTH'] log.warning(f'({projector.entry.name}) Invalid error status response "{data}": length != {count}') return if not data.isnumeric(): # Bad data - ignore log.warning(f'({projector.entry.name}) Invalid error status response "{data}"') return if int(data) == 0: projector.projector_errors = None # No errors return # We have some sort of status error, so check out what it/they are projector.projector_errors = {} fan, lamp, temp, cover, filt, other = (data[PJLINK_ERST_DATA['FAN']], data[PJLINK_ERST_DATA['LAMP']], data[PJLINK_ERST_DATA['TEMP']], data[PJLINK_ERST_DATA['COVER']], data[PJLINK_ERST_DATA['FILTER']], data[PJLINK_ERST_DATA['OTHER']]) if fan != PJLINK_ERST_STATUS[S_OK]: projector.projector_errors[PJLINK_ERST_LIST['FAN']] = PJLINK_ERST_STATUS[fan] if lamp != PJLINK_ERST_STATUS[S_OK]: projector.projector_errors[PJLINK_ERST_LIST['LAMP']] = PJLINK_ERST_STATUS[lamp] if temp != PJLINK_ERST_STATUS[S_OK]: projector.projector_errors[PJLINK_ERST_LIST['TEMP']] = PJLINK_ERST_STATUS[temp] if cover != PJLINK_ERST_STATUS[S_OK]: projector.projector_errors[PJLINK_ERST_LIST['COVER']] = PJLINK_ERST_STATUS[cover] if filt != PJLINK_ERST_STATUS[S_OK]: projector.projector_errors[PJLINK_ERST_LIST['FILTER']] = PJLINK_ERST_STATUS[filt] if other != PJLINK_ERST_STATUS[S_OK]: projector.projector_errors[PJLINK_ERST_LIST['OTHER']] = PJLINK_ERST_STATUS[other] return _pjlink_functions['ERST'] = process_erst def process_inf1(projector, data): """ Manufacturer name set in projector. Updates projector.manufacturer :param projector: Projector instance :param data: Projector manufacturer """ projector.manufacturer = data log.debug(f'({projector.entry.name}) Setting projector manufacturer data to "{projector.manufacturer}"') return _pjlink_functions['INF1'] = process_inf1 def process_inf2(projector, data): """ Projector Model set in projector. Updates projector.model. :param projector: Projector instance :param data: Model name """ projector.model = data log.debug(f'({projector.entry.name}) Setting projector model to "{projector.model}"') return _pjlink_functions['INF2'] = process_inf2 def process_info(projector, data): """ Any extra info set in projector. Updates projector.other_info. :param projector: Projector instance :param data: Projector other info """ projector.other_info = data log.debug(f'({projector.entry.name}) Setting projector other_info to "{projector.other_info}"') return _pjlink_functions['INFO'] = process_info def process_inpt(projector, data): """ Current source input selected. See PJLink specification for format. Update projector.source :param projector: Projector instance :param data: Currently selected source """ # First, see if we have a valid input based on what is installed (if available) if projector.source_available is not None: # We have available inputs, so verify it's in the list if data not in projector.source_available: log.warning(f'({projector.entry.name}) Input source not listed in available sources - ignoring') return elif data not in PJLINK_DEFAULT_CODES: # Hmm - no sources available yet, so check with PJLink defaults log.warning(f'({projector.entry.name}) Input source not listed as a PJLink valid source - ignoring') return projector.source = data log.debug(f'({projector.entry.name}) Setting current source to "{projector.source}"') return _pjlink_functions['INPT'] = process_inpt def process_inst(projector, data): """ Available source inputs. See PJLink specification for format. Updates projector.source_available :param projector: Projector instance :param data: Sources list """ sources = [] check = data.split() for source in check: sources.append(source) sources.sort() projector.source_available = sources log.debug(f'({projector.entry.name}) Setting projector source_available to "{projector.source_available}"') projector.projectorUpdateIcons.emit() return _pjlink_functions['INST'] = process_inst def process_lamp(projector, data): """ Lamp(s) status. See PJLink Specifications for format. Data may have more than 1 lamp to process. Update projector.lamp dictionary with lamp status. :param projector: Projector instance :param data: Lamp(s) status. """ lamps = [] lamp_list = data.split() if len(lamp_list) < 2: # Invalid data - not enough information log.warning(f'({projector.entry.name}) process_lamp(): Invalid data "{data}" - Missing data') return else: while lamp_list: if not lamp_list[0].isnumeric() or not lamp_list[1].isnumeric(): # Invalid data - we'll ignore the rest for now log.warning(f'({projector.entry.name}) process_lamp(): Invalid data "{data}"') return fill = {'Hours': int(lamp_list[0]), 'On': False if lamp_list[1] == '0' else True} lamps.append(fill) lamp_list.pop(0) # Remove lamp hours lamp_list.pop(0) # Remove lamp on/off projector.lamp = lamps return _pjlink_functions['LAMP'] = process_lamp def _process_lkup(projector, data): """ Process UDP request indicating remote is available for connection :param projector: Projector instance :param data: Data packet from remote """ log.debug(f'({projector.entry.name}) Processing LKUP command') if Registry().get('settings').value('projector/connect when LKUP received'): projector.connect_to_host() _pjlink_functions['LKUP'] = _process_lkup def process_name(projector, data): """ Projector name set in projector. Updates projector.pjlink_name :param projector: Projector instance :param data: Projector name """ projector.pjlink_name = data log.debug(f'({projector.entry.name}) Setting projector PJLink name to "{projector.pjlink_name}"') return _pjlink_functions['NAME'] = process_name def process_pjlink(projector, data): """ Process initial socket connection to terminal. :param projector: Projector instance :param data: Initial packet with authentication scheme """ log.debug(f'({projector.entry.name}) Processing PJLINK command') chk = data.split(' ') if (len(chk[0]) != 1) or (chk[0] not in ('0', '1')): # Invalid - after splitting, first field should be 1 character, either '0' or '1' only log.error(f'({projector.entry.name}) Invalid initial authentication scheme - aborting') return E_AUTHENTICATION elif chk[0] == '0': # Normal connection no authentication if len(chk) > 1: # Invalid data - there should be nothing after a normal authentication scheme log.error(f'({projector.entry.name}) Normal connection with extra information - aborting') return E_NO_AUTHENTICATION elif projector.pin: log.error(f'({projector.entry.name}) Normal connection but PIN set - aborting') return E_NO_AUTHENTICATION log.debug(f'({projector.entry.name}) PJLINK: Returning S_CONNECT') return S_CONNECT elif chk[0] == '1': if len(chk) < 2: # Not enough information for authenticated connection log.error(f'({projector.entry.name}) Authenticated connection but not enough info - aborting') return E_NO_AUTHENTICATION elif len(chk[-1]) != PJLINK_TOKEN_SIZE: # Bad token - incorrect size log.error(f'({projector.entry.name}) Authentication token invalid (size) - aborting') return E_NO_AUTHENTICATION elif not all(c in string.hexdigits for c in chk[-1]): # Bad token - not hexadecimal log.error(f'({projector.entry.name}) Authentication token invalid (not a hexadecimal number) - aborting') return E_NO_AUTHENTICATION elif not projector.pin: log.error(f'({projector.entry.name}) Authenticate connection but no PIN - aborting') return E_NO_AUTHENTICATION log.debug(f'({projector.entry.name}) PJLINK: Returning S_AUTHENTICATE') return S_AUTHENTICATE _pjlink_functions['PJLINK'] = process_pjlink def process_powr(projector, data): """ Power status. See PJLink specification for format. Update projector.power with status. Update icons if change from previous setting. :param projector: Projector instance :param data: Power status """ log.debug(f'({projector.entry.name}) Processing POWR command') if data not in PJLINK_POWR_STATUS: # Log unknown status response log.warning(f'({projector.entry.name}) Unknown power response: "{data}"') return power = PJLINK_POWR_STATUS[data] if projector.power != power: projector.power = power projector.change_status(PJLINK_POWR_STATUS[data]) projector.projectorUpdateIcons.emit() if power == S_ON: # Input sources list should only be available after power on, so update here projector.send_command('INST') if projector.power in [S_ON, S_STANDBY, S_OFF]: projector.status_timer_delete(cmd='POWR') return _pjlink_functions['POWR'] = process_powr def process_rfil(projector, data): """ Process replacement filter type :param projector: Projector instance :param data: Filter replacement model number """ if projector.model_filter is None: projector.model_filter = data else: log.warning(f'({projector.entry.name}) Filter model already set') log.warning(f'({projector.entry.name}) Saved model: "{projector.model_filter}"') log.warning(f'({projector.entry.name}) New model: "{data}"') _pjlink_functions['RFIL'] = process_rfil def process_rlmp(projector, data): """ Process replacement lamp type :param projector: Projector instance :param data: Lamp replacement model number """ if projector.model_lamp is None: projector.model_lamp = data else: log.warning(f'({projector.entry.name}) Lamp model already set') log.warning(f'({projector.entry.name}) Saved lamp: "{projector.model_lamp}"') log.warning(f'({projector.entry.name}) New lamp: "{data}"') _pjlink_functions['RLMP'] = process_rlmp def process_snum(projector, data): """ Serial number of projector. :param projector: Projector instance :param data: Serial number from projector. """ if projector.serial_no is None: log.debug(f'({projector.entry.name}) Setting projector serial number to "{data}"') projector.serial_no = data projector.db_update = False return # Compare serial numbers and see if we got the same projector if projector.serial_no != data: log.warning(f'({projector.entry.name}) Projector serial number does not match saved serial number') log.warning(f'({projector.entry.name}) Saved: "{projector.serial_no}"') log.warning(f'({projector.entry.name}) Received: "{data}"') log.warning(f'({projector.entry.name}) NOT saving serial number') projector.serial_no_received = data _pjlink_functions['SNUM'] = process_snum def _process_srch(projector=None, data=None): """ Process the SRCH command. SRCH is processed by terminals so we ignore any packet. UDP command to find active CLSS 2 projectors. Reply is ACKN. :param projector: Projector instance (actually ignored for this command) :param data: Data in packet """ msg = 'SRCH packet detected - ignoring' name = '' if projector is not None: name = f'({projector.entry.name}) ' log.warning(f'{name}{msg}') _pjlink_functions['SRCH'] = _process_srch def _process_sver(projector, data): """ Software version of projector :param projector: Projector instance :param data: Software version of projector """ if len(data) > PJLINK_SVER_MAX_LEN: # Defined in specs 0-32 characters max log.warning(f'({projector.name}) Invalid software version - too long') return elif projector.sw_version == data: log.debug(f'({projector.name}) Software version unchanged - returning') return elif projector.sw_version is not None: log.debug(f'({projector.name}) Old software version "{projector.sw_version}"') log.debug(f'({projector.name}) New software version "{data}"') # Software version changed - save log.debug(f'({projector.entry.name}) Setting projector software version to "{data}"') projector.sw_version = data projector.db_update = True _pjlink_functions['SVER'] = _process_sver