# -*- coding: utf-8 -*- ########################################################################## # OpenLP - Open Source Lyrics Projection # # ---------------------------------------------------------------------- # # Copyright (c) 2008-2022 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. """ 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_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'] # 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('({ip}) Processing command "{cmd}" with data "{data}"'.format(ip=projector.entry.name, cmd=cmd, 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('({ip}) Unable to process command="{cmd}" (Future option?)'.format(ip=projector.entry.name, cmd=cmd)) return elif _data == 'OK': log.debug('({ip}) Command "{cmd}" returned OK'.format(ip=projector.entry.name, cmd=cmd)) # 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('({ip}) {cmd}: {err}'.format(ip=projector.entry.name, cmd=cmd, err=STATUS_MSG[PJLINK_ERRORS[_data]])) return PJLINK_ERRORS[_data] # Command checks already passed log.debug('({ip}) Calling function for {cmd}'.format(ip=projector.entry.name, cmd=cmd)) return pjlink_functions[cmd](projector=projector, data=data) def process_ackn(projector, data): """ Process the ACKN command. :param projector: Projector instance :param data: Data in packet """ # TODO: Have to rethink this one pass def process_avmt(projector, data): """ Process shutter and speaker status. See PJLink specification for format. Update projector.mute (audio) and projector.shutter (video shutter). 10 = Shutter open, audio unchanged 11 = Shutter closed, audio unchanged 20 = Shutter unchanged, Audio normal 21 = Shutter unchanged, Audio muted 30 = Shutter open, audio muted 31 = Shutter closed, audio normal :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('({ip}) Invalid av mute response: {data}'.format(ip=projector.entry.name, data=data)) return shutter = settings[data]['shutter'] mute = settings[data]['mute'] # Check if we need to update the icons update_icons = (shutter != projector.shutter) or (mute != projector.mute) if update_icons: if projector.shutter != shutter: projector.shutter = shutter log.debug('({ip}) Setting shutter to {chk}'.format(ip=projector.entry.name, chk='closed' if shutter else 'open')) if projector.mute != mute: projector.mute = mute log.debug('({ip}) Setting speaker to {chk}'.format(ip=projector.entry.name, chk='muted' if shutter else 'normal')) if 'AVMT' in projector.status_timer_checks: projector.status_timer_delete('AVMT') projector.projectorUpdateIcons.emit() return 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('({ip}) Non-standard CLSS reply: "{data}"'.format(ip=projector.entry.name, data=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('({ip}) No numbers found in class version reply "{data}" - ' 'defaulting to class "1"'.format(ip=projector.entry.name, data=data)) clss = '1' else: clss = chk[0] # Should only be the first match elif not data.isdigit(): log.warning('({ip}) NAN CLSS version reply "{data}" - ' 'defaulting to class "1"'.format(ip=projector.entry.name, data=data)) clss = '1' else: clss = data projector.pjlink_class = clss log.debug('({ip}) Setting pjlink_class for this projector to "{data}"'.format(ip=projector.entry.name, data=projector.pjlink_class)) if projector.no_poll: return # Since we call this one on first connect, setup polling from here log.debug('({ip}) process_pjlink(): Starting timer'.format(ip=projector.entry.name)) projector.poll_timer.setInterval(1000) # Set 1 second for initial information projector.poll_timer.start() return 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('({ip}) Invalid error status response "{data}": length != {count}'.format(ip=projector.entry.name, data=data, count=count)) return if not data.isnumeric(): # Bad data - ignore log.warning('({ip}) Invalid error status response "{data}"'.format(ip=projector.entry.name, data=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 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('({ip}) Setting projector manufacturer data to "{data}"'.format(ip=projector.entry.name, data=projector.manufacturer)) return 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('({ip}) Setting projector model to "{data}"'.format(ip=projector.entry.name, data=projector.model)) return 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('({ip}) Setting projector other_info to "{data}"'.format(ip=projector.entry.name, data=projector.other_info)) return 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('({ip}) Input source not listed in available sources - ' 'ignoring'.format(ip=projector.entry.name)) return elif data not in PJLINK_DEFAULT_CODES: # Hmm - no sources available yet, so check with PJLink defaults log.warning('({ip}) Input source not listed as a PJLink valid source ' '- ignoring'.format(ip=projector.entry.name)) return projector.source = data log.debug('({ip}) Setting current source to "{data}"'.format(ip=projector.entry.name, data=projector.source)) return 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('({ip}) Setting projector source_available to "{data}"'.format(ip=projector.entry.name, data=projector.source_available)) projector.projectorUpdateIcons.emit() return 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('({ip}) process_lamp(): Invalid data "{data}" - ' 'Missing data'.format(ip=projector.entry.name, data=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('({ip}) process_lamp(): Invalid data "{data}"'.format(ip=projector.entry.name, 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 def process_lkup(projector, data): """ Process reply indicating remote is available for connection :param projector: Projector instance :param data: Data packet from remote """ log.debug('({ip}) Processing LKUP command'.format(ip=projector.entry.name)) if Registry().get('settings').value('projector/connect when LKUP received'): projector.connect_to_host() 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('({ip}) Setting projector PJLink name to "{data}"'.format(ip=projector.entry.name, data=projector.pjlink_name)) return def process_pjlink(projector, data): """ Process initial socket connection to terminal. :param projector: Projector instance :param data: Initial packet with authentication scheme """ log.debug('({ip}) Processing PJLINK command'.format(ip=projector.entry.name)) chk = data.split(' ') if len(chk[0]) != 1: # Invalid - after splitting, first field should be 1 character, either '0' or '1' only log.error('({ip}) Invalid initial authentication scheme - aborting'.format(ip=projector.entry.name)) 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('({ip}) Normal connection with extra information - aborting'.format(ip=projector.entry.name)) return E_NO_AUTHENTICATION elif projector.pin: log.error('({ip}) Normal connection but PIN set - aborting'.format(ip=projector.entry.name)) return E_NO_AUTHENTICATION log.debug('({ip}) PJLINK: Returning S_CONNECT'.format(ip=projector.entry.name)) return S_CONNECT elif chk[0] == '1': if len(chk) < 2: # Not enough information for authenticated connection log.error('({ip}) Authenticated connection but not enough info - aborting'.format(ip=projector.entry.name)) return E_NO_AUTHENTICATION elif len(chk[-1]) != PJLINK_TOKEN_SIZE: # Bad token - incorrect size log.error('({ip}) Authentication token invalid (size) - aborting'.format(ip=projector.entry.name)) return E_NO_AUTHENTICATION elif not all(c in string.hexdigits for c in chk[-1]): # Bad token - not hexadecimal log.error('({ip}) Authentication token invalid (not a hexadecimal number) ' '- aborting'.format(ip=projector.entry.name)) return E_NO_AUTHENTICATION elif not projector.pin: log.error('({ip}) Authenticate connection but no PIN - aborting'.format(ip=projector.entry.name)) return E_NO_AUTHENTICATION log.debug('({ip}) PJLINK: Returning S_AUTHENTICATE'.format(ip=projector.entry.name)) return S_AUTHENTICATE 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('({ip}) Processing POWR command'.format(ip=projector.entry.name)) if data not in PJLINK_POWR_STATUS: # Log unknown status response log.warning('({ip}) Unknown power response: "{data}"'.format(ip=projector.entry.name, data=data)) return power = PJLINK_POWR_STATUS[data] update_icons = projector.power != power if update_icons: 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] and 'POWR' in projector.status_timer_checks: projector.status_timer_delete(cmd='POWR') return 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('({ip}) Filter model already set'.format(ip=projector.entry.name)) log.warning('({ip}) Saved model: "{old}"'.format(ip=projector.entry.name, old=projector.model_filter)) log.warning('({ip}) New model: "{new}"'.format(ip=projector.entry.name, new=data)) 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('({ip}) Lamp model already set'.format(ip=projector.entry.name)) log.warning('({ip}) Saved lamp: "{old}"'.format(ip=projector.entry.name, old=projector.model_lamp)) log.warning('({ip}) New lamp: "{new}"'.format(ip=projector.entry.name, new=data)) 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('({ip}) Setting projector serial number to "{data}"'.format(ip=projector.entry.name, data=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('({ip}) Projector serial number does not match saved serial ' 'number'.format(ip=projector.entry.name)) log.warning('({ip}) Saved: "{old}"'.format(ip=projector.entry.name, old=projector.serial_no)) log.warning('({ip}) Received: "{new}"'.format(ip=projector.entry.name, new=data)) log.warning('({ip}) NOT saving serial number'.format(ip=projector.entry.name)) projector.serial_no_received = data def process_srch(projector=None, data=None): """ Process the SRCH command. SRCH is processed by terminals so we ignore any packet. :param projector: Projector instance (actually ignored for this command) :param data: Data in packet """ log.warning("({ip}) SRCH packet detected - ignoring".format(ip=projector.entry.ip)) return def process_sver(projector, data): """ Software version of projector :param projector: Projector instance :param data: Software version of projector """ if len(data) > 32: # Defined in specs max version is 32 characters log.warning('Invalid software version - too long') return if projector.sw_version is not None: if projector.sw_version == data: log.debug('({ip}) Software version same as saved version - returning'.format(ip=projector.entry.name)) return log.warning('({ip}) Projector software version does not match saved ' 'software version'.format(ip=projector.entry.name)) log.warning('({ip}) Saved: "{old}"'.format(ip=projector.entry.name, old=projector.sw_version)) log.warning('({ip}) Received: "{new}"'.format(ip=projector.entry.name, new=data)) log.warning('({ip}) Updating software version'.format(ip=projector.entry.name)) log.debug('({ip}) Setting projector software version to "{data}"'.format(ip=projector.entry.name, data=data)) projector.sw_version = data projector.db_update = True # Map command to function. pjlink_functions = { 'ACKN': process_ackn, # Class 2 (command is SRCH) 'AVMT': process_avmt, 'CLSS': process_clss, 'ERST': process_erst, 'INFO': process_info, 'INF1': process_inf1, 'INF2': process_inf2, 'INPT': process_inpt, 'INST': process_inst, 'LAMP': process_lamp, 'LKUP': process_lkup, # Class 2 (reply only - no cmd) 'NAME': process_name, 'PJLINK': process_pjlink, 'POWR': process_powr, 'SNUM': process_snum, 'SRCH': process_srch, # Class 2 (reply is ACKN) 'SVER': process_sver, 'RFIL': process_rfil, 'RLMP': process_rlmp }