openlp/openlp/core/projectors/pjlinkcommands.py

605 lines
21 KiB
Python

# -*- 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 <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.
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