openlp/openlp/core/projectors/pjlinkcommands.py

554 lines
22 KiB
Python

# -*- 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 <https://www.gnu.org/licenses/>. #
##########################################################################
"""
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
}