mirror of https://gitlab.com/openlp/openlp.git
967 lines
41 KiB
Python
967 lines
41 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.pjlink` module provides the necessary functions for connecting to a PJLink-capable
|
|
projector.
|
|
|
|
PJLink Class 1 Specifications
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
|
|
Website: http://pjlink.jbmia.or.jp/english/dl_class1.html
|
|
|
|
- Section 5-1 PJLink Specifications
|
|
- Section 5-5 Guidelines for Input Terminals
|
|
|
|
PJLink Class 2 Specifications
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
|
|
Website: http://pjlink.jbmia.or.jp/english/dl_class2.html
|
|
|
|
- Section 5-1 PJLink Specifications
|
|
- Section 5-5 Guidelines for Input Terminals
|
|
|
|
.. note:
|
|
Function names follow the following syntax::
|
|
|
|
def process_CCCC(...):
|
|
|
|
where ``CCCC`` is the PJLink command being processed
|
|
"""
|
|
import logging
|
|
from codecs import decode
|
|
from copy import copy
|
|
|
|
from PyQt5 import QtCore, QtNetwork
|
|
|
|
from openlp.core.common import qmd5_hash
|
|
from openlp.core.common.i18n import translate
|
|
from openlp.core.common.registry import Registry
|
|
from openlp.core.projectors.pjlinkcommands import process_command
|
|
from openlp.core.projectors.constants import CONNECTION_ERRORS, E_AUTHENTICATION, E_CONNECTION_REFUSED, E_GENERAL, \
|
|
E_NETWORK, E_NOT_CONNECTED, E_SOCKET_TIMEOUT, PJLINK_CLASS, \
|
|
PJLINK_MAX_PACKET, PJLINK_PORT, PJLINK_PREFIX, PJLINK_SUFFIX, \
|
|
PJLINK_VALID_CMD, PROJECTOR_STATE, QSOCKET_STATE, S_AUTHENTICATE, S_CONNECT, S_CONNECTED, S_CONNECTING, \
|
|
S_DATA_OK, S_NOT_CONNECTED, S_OFF, S_OK, S_ON, STATUS_CODE, STATUS_MSG
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
log.debug('pjlink loaded')
|
|
|
|
__all__ = ['PJLink', 'PJLinkUDP']
|
|
|
|
# Shortcuts
|
|
SocketError = QtNetwork.QAbstractSocket.SocketError
|
|
SocketSTate = QtNetwork.QAbstractSocket.SocketState
|
|
|
|
# Add prefix here, but defer linkclass expansion until later when we have the actual
|
|
# PJLink class for the command
|
|
PJLINK_HEADER = f'{PJLINK_PREFIX}{{linkclass}}'
|
|
|
|
|
|
class PJLinkUDP(QtNetwork.QUdpSocket):
|
|
"""
|
|
Socket service for PJLink UDP socket.
|
|
"""
|
|
|
|
data_received = QtCore.pyqtSignal(QtNetwork.QHostAddress, int, str, name='udp_data') # host, port, data
|
|
|
|
def __init__(self, port=PJLINK_PORT):
|
|
"""
|
|
Socket services for PJLink UDP packets.
|
|
|
|
Since all UDP packets from any projector will come into the same
|
|
port, process UDP packets here then route to the appropriate
|
|
projector instance as needed.
|
|
|
|
:param port: UDP port to listen on
|
|
"""
|
|
super().__init__()
|
|
self.port = port
|
|
# Local defines
|
|
self.search_active = False
|
|
self.search_time = 30000 # 30 seconds for allowed time
|
|
self.search_timer = QtCore.QTimer()
|
|
self.udp_broadcast_listen_setting = False
|
|
self.settings = Registry().get('settings')
|
|
log.debug(f'(UDP:{self.port}) PJLinkUDP() Initialized')
|
|
if self.settings.value('projector/udp broadcast listen'):
|
|
self.udp_start()
|
|
|
|
def udp_start(self):
|
|
"""
|
|
Start listening on UDP port
|
|
"""
|
|
log.debug(f'(UDP:{self.port}) Start called')
|
|
self.readyRead.connect(self.get_datagram)
|
|
self.check_settings(checked=self.settings.value('projector/udp broadcast listen'))
|
|
|
|
def udp_stop(self):
|
|
"""
|
|
Stop listening on UDP port
|
|
"""
|
|
log.debug(f'(UDP:{self.port}) Stopping listener')
|
|
self.close()
|
|
self.readyRead.disconnect(self.get_datagram)
|
|
|
|
@QtCore.pyqtSlot()
|
|
def get_datagram(self):
|
|
"""
|
|
Retrieve packet and basic checks
|
|
"""
|
|
log.debug(f'(UDP:{self.port}) get_datagram() - Receiving data')
|
|
read_size = self.pendingDatagramSize()
|
|
if -1 == read_size:
|
|
log.warning(f'(UDP:{self.port}) No data (-1)')
|
|
return
|
|
elif 0 == read_size:
|
|
log.warning(f'(UDP:{self.port}) get_datagram() called when pending data size is 0')
|
|
return
|
|
elif read_size > PJLINK_MAX_PACKET:
|
|
log.warning(f'(UDP:{self.port}) UDP Packet too large ({read_size} bytes)- ignoring')
|
|
return
|
|
data_in, peer_host, peer_port = self.readDatagram(read_size)
|
|
data = data_in.decode('utf-8') if isinstance(data_in, bytes) else data_in
|
|
log.debug(f'(UDP:{self.port}) {len(data)} bytes received from {peer_host.toString()}')
|
|
log.debug(f'(UDP:{self.port}) packet "{data}"')
|
|
log.debug(f'(UDP:{self.port}) Sending data_received signal to projectors')
|
|
self.data_received.emit(peer_host, self.localPort(), data)
|
|
return
|
|
|
|
def search_start(self):
|
|
"""
|
|
Start search for projectors on local network
|
|
"""
|
|
self.search_active = True
|
|
# TODO: Send SRCH packet here
|
|
self.search_timer.singleShot(self.search_time, self.search_stop)
|
|
|
|
@QtCore.pyqtSlot()
|
|
def search_stop(self):
|
|
"""
|
|
Stop search
|
|
"""
|
|
self.search_active = False
|
|
self.search_timer.stop()
|
|
|
|
def check_settings(self, checked):
|
|
"""
|
|
Update UDP listening state based on settings change.
|
|
NOTE: This method is called by projector settings tab and setup/removed by ProjectorManager
|
|
"""
|
|
if self.udp_broadcast_listen_setting == checked:
|
|
log.debug(f'(UDP:{self.port}) No change to status - skipping')
|
|
return
|
|
self.udp_broadcast_listen_setting = checked
|
|
if self.udp_broadcast_listen_setting:
|
|
if self.state() == self.ListeningState:
|
|
log.debug(f'(UDP:{self.port}) Already listening - skipping')
|
|
return
|
|
self.bind(self.port)
|
|
log.debug(f'(UDP:{self.port}) Listening')
|
|
else:
|
|
# Close socket
|
|
self.udp_stop()
|
|
|
|
|
|
class PJLink(QtNetwork.QTcpSocket):
|
|
"""
|
|
Socket services for PJLink TCP packets.
|
|
"""
|
|
# Signals sent by this module
|
|
projectorChangeStatus = QtCore.pyqtSignal(str, int, str)
|
|
projectorStatus = QtCore.pyqtSignal(int) # Status update
|
|
projectorAuthentication = QtCore.pyqtSignal(str) # Authentication error
|
|
projectorNoAuthentication = QtCore.pyqtSignal(str) # PIN set and no authentication needed
|
|
projectorReceivedData = QtCore.pyqtSignal() # Notify when received data finished processing
|
|
projectorUpdateIcons = QtCore.pyqtSignal() # Update the status icons on toolbar
|
|
# Deprecated
|
|
changeStatus = projectorChangeStatus # Use projectorChangeStatus
|
|
|
|
def __init__(self, projector, *args, **kwargs):
|
|
"""
|
|
Setup for instance.
|
|
Options should be in kwargs except for port which does have a default.
|
|
|
|
:param projector: Database record of projector
|
|
|
|
Optional parameters
|
|
:param poll_time: Time (in seconds) to poll connected projector
|
|
:param socket_timeout: Time (in seconds) to abort the connection if no response
|
|
"""
|
|
log.debug(f'PJLink(projector="{projector}", args="{args}" kwargs="{kwargs}")')
|
|
super().__init__()
|
|
self.settings_section = 'projector'
|
|
self.db = projector
|
|
self.entry = self.db # Deprecated use self.db
|
|
self.ip = self.entry.ip
|
|
self.qhost = QtNetwork.QHostAddress(self.ip)
|
|
self.location = self.entry.location
|
|
self.mac_adx = self.entry.mac_adx
|
|
self.name = self.entry.name
|
|
self.notes = self.entry.notes
|
|
self.pin = self.entry.pin
|
|
self.port = int(self.entry.port)
|
|
self.pjlink_class = PJLINK_CLASS if self.entry.pjlink_class is None else self.entry.pjlink_class
|
|
self.ackn_list = {} # Replies from online projectors (Class 2 option)
|
|
self.db_update = False # Use to check if db needs to be updated prior to exiting
|
|
# Poll time 20 seconds unless called with something else
|
|
self.poll_time = 20000 if 'poll_time' not in kwargs else kwargs['poll_time'] * 1000
|
|
# Socket timeout (in case of brain-dead projectors) 5 seconds unless called with something else
|
|
self.socket_timeout = 5000 if 'socket_timeout' not in kwargs else kwargs['socket_timeout'] * 1000
|
|
# In case we're called from somewhere that only wants information
|
|
self.no_poll = 'no_poll' in kwargs
|
|
self.status_connect = S_NOT_CONNECTED
|
|
self.last_command = ''
|
|
self.projector_status = S_NOT_CONNECTED
|
|
self.error_status = S_OK
|
|
# Socket information
|
|
# Add enough space to input buffer for extraneous \n \r
|
|
self.max_size = PJLINK_MAX_PACKET + 2
|
|
self.setReadBufferSize(self.max_size)
|
|
self.reset_information()
|
|
self.send_queue = []
|
|
self.priority_queue = []
|
|
self.send_busy = False
|
|
# Poll timer for status updates
|
|
self.poll_timer = QtCore.QTimer(self) # Timer that calls the poll_loop
|
|
self.poll_timer.setInterval(self.poll_time)
|
|
self.poll_timer.timeout.connect(self.poll_loop)
|
|
# Socket timer for some possible brain-dead projectors or network issues
|
|
self.socket_timer = QtCore.QTimer(self)
|
|
self.socket_timer.setInterval(self.socket_timeout)
|
|
self.socket_timer.timeout.connect(self.socket_abort)
|
|
# Timer for doing status updates for commands that change state and should update faster
|
|
self.status_timer_checks = {} # Keep track of events for the status timer
|
|
self.status_timer = QtCore.QTimer(self)
|
|
self.status_timer.setInterval(2000) # 2 second interval should be fast enough
|
|
self.status_timer.timeout.connect(self.status_timer_update)
|
|
# Socket status signals
|
|
self.connected.connect(self.check_login)
|
|
self.disconnected.connect(self.disconnect_from_host)
|
|
self.error.connect(self.get_error)
|
|
self.projectorReceivedData.connect(self._send_command)
|
|
|
|
def reset_information(self):
|
|
"""
|
|
Initialize instance variables. Also used to reset projector-specific information to default.
|
|
"""
|
|
conn_state = STATUS_CODE[QSOCKET_STATE[self.state()]]
|
|
log.debug(f'({self.entry.name}) reset_information() connect status is {conn_state}')
|
|
self.fan = None # ERST
|
|
self.filter_time = None # FILT
|
|
self.lamp = None # LAMP
|
|
self.mac_adx_received = None # ACKN
|
|
self.manufacturer = None # INF1
|
|
self.model = None # INF2
|
|
self.model_filter = None # RFIL
|
|
self.model_lamp = None # RLMP
|
|
self.mute = None # AVMT
|
|
self.other_info = None # INFO
|
|
self.pjlink_class = copy(PJLINK_CLASS)
|
|
self.pjlink_name = None # NAME
|
|
self.power = S_OFF # POWR
|
|
self.projector_errors = {} # Full ERST errors
|
|
self.serial_no = None # SNUM
|
|
self.serial_no_received = None
|
|
self.sw_version = None # SVER
|
|
self.sw_version_received = None
|
|
self.shutter = None # AVMT
|
|
self.source_available = None # INST
|
|
self.source = None # INPT
|
|
# These should be part of PJLink() class, but set here for convenience
|
|
if hasattr(self, 'poll_timer'):
|
|
log.debug(f'({self.entry.name}): Calling poll_timer.stop()')
|
|
self.poll_timer.stop()
|
|
if hasattr(self, 'socket_timer'):
|
|
log.debug(f'({self.entry.name}): Calling socket_timer.stop()')
|
|
self.socket_timer.stop()
|
|
if hasattr(self, 'status_timer'):
|
|
log.debug(f'({self.entry.name}): Calling status_timer.stop()')
|
|
self.status_timer.stop()
|
|
self.status_timer_checks = {}
|
|
self.send_busy = False
|
|
self.send_queue = []
|
|
self.priority_queue = []
|
|
|
|
def socket_abort(self):
|
|
"""
|
|
Aborts connection and closes socket in case of brain-dead projectors.
|
|
Should normally be called by socket_timer().
|
|
"""
|
|
log.debug(f'({self.entry.name}) socket_abort() - Killing connection')
|
|
self.disconnect_from_host(abort=True)
|
|
|
|
def poll_loop(self):
|
|
"""
|
|
Retrieve information from projector that changes.
|
|
Normally called by timer().
|
|
"""
|
|
if QSOCKET_STATE[self.state()] != S_CONNECTED:
|
|
log.warning(f'({self.entry.name}) poll_loop(): Not connected - returning')
|
|
# Stop timer just in case it's missed elsewhere
|
|
self.poll_timer.stop()
|
|
return
|
|
log.debug(f'({self.entry.name}) poll_loop(): Updating projector status')
|
|
# The following commands do not change, so only check them once
|
|
# Call them first in case other functions rely on something here
|
|
if self.power == S_ON and self.source_available is None:
|
|
self.send_command('INST')
|
|
if self.other_info is None:
|
|
self.send_command('INFO')
|
|
if self.manufacturer is None:
|
|
self.send_command('INF1')
|
|
if self.model is None:
|
|
self.send_command('INF2')
|
|
if self.pjlink_name is None:
|
|
self.send_command('NAME')
|
|
if self.pjlink_class == '2':
|
|
# Class 2 specific checks
|
|
if self.serial_no is None:
|
|
self.send_command('SNUM')
|
|
if self.sw_version is None:
|
|
self.send_command('SVER')
|
|
if self.model_filter is None:
|
|
self.send_command('RFIL')
|
|
if self.model_lamp is None:
|
|
self.send_command('RLMP')
|
|
# These commands may change during connection
|
|
check_list = ['POWR', 'ERST', 'LAMP', 'AVMT', 'INPT']
|
|
if self.pjlink_class == '2':
|
|
check_list.extend(['FILT', 'FREZ'])
|
|
for command in check_list:
|
|
self.send_command(command)
|
|
# Reset the poll_timer for normal operations in case of initial connection
|
|
self.poll_timer.setInterval(self.poll_time)
|
|
|
|
def _get_status(self, status):
|
|
"""
|
|
Helper to retrieve status/error codes and convert to strings.
|
|
|
|
:param status: Status/Error code
|
|
:returns: tuple (-1 if code not INT, None)
|
|
:returns: tuple (string: code as string, None if no description)
|
|
:returns: tuple (string: code as string, string: Status/Error description)
|
|
"""
|
|
if not isinstance(status, int):
|
|
return -1, None
|
|
elif status not in STATUS_MSG:
|
|
return None, None
|
|
else:
|
|
return STATUS_CODE[status], STATUS_MSG[status]
|
|
|
|
def change_status(self, status, msg=None):
|
|
"""
|
|
Check connection/error status, set status for projector, then emit status change signal
|
|
for gui to allow changing the icons.
|
|
|
|
:param status: Status code
|
|
:param msg: Optional message
|
|
"""
|
|
if status in STATUS_CODE:
|
|
log.debug(f'({self.entry.name}) Changing status to {STATUS_CODE[status]} '
|
|
f'"{STATUS_MSG[status] if msg is None else msg}"')
|
|
else:
|
|
log.warning(f'({self.entry.name}) Unknown status change code: {status}')
|
|
return
|
|
if status in CONNECTION_ERRORS:
|
|
# Connection state error affects both socket and projector
|
|
self.error_status = status
|
|
self.status_connect = E_NOT_CONNECTED
|
|
elif status >= S_NOT_CONNECTED and status in QSOCKET_STATE:
|
|
# Socket connection status update
|
|
self.status_connect = status
|
|
# Check if we need to update error state as well
|
|
if self.error_status != S_OK and status != S_NOT_CONNECTED:
|
|
self.error_status = S_OK
|
|
elif status >= S_NOT_CONNECTED and status in PROJECTOR_STATE:
|
|
# Only affects the projector status
|
|
self.projector_status = status
|
|
|
|
# These log entries are for troubleshooting only
|
|
(status_code, status_message) = self._get_status(self.status_connect)
|
|
log.debug(f'({self.entry.name}) status_connect: {status_code}: "{status_message if msg is None else msg}"')
|
|
(status_code, status_message) = self._get_status(self.projector_status)
|
|
log.debug(f'({self.entry.name}) projector_status: {status_code}: "{status_message if msg is None else msg}"')
|
|
(status_code, status_message) = self._get_status(self.error_status)
|
|
log.debug(f'({self.entry.name}) error_status: {status_code}: "{status_message if msg is None else msg}"')
|
|
|
|
# Now that we logged extra information for debugging, broadcast the original change/message
|
|
# Check for connection errors first
|
|
if self.error_status != S_OK:
|
|
log.debug(f'({self.entry.name}) Signalling error code')
|
|
code, message = self._get_status(self.error_status)
|
|
status = self.error_status
|
|
else:
|
|
log.debug(f'({self.entry.name}) Signalling status code')
|
|
code, message = self._get_status(status)
|
|
if msg is not None:
|
|
message = msg
|
|
elif message is None:
|
|
# No message for status code
|
|
message = translate('OpenLP.PJLink', 'No message') if msg is None else msg
|
|
|
|
self.changeStatus.emit(self.ip, status, message)
|
|
self.projectorUpdateIcons.emit()
|
|
|
|
@QtCore.pyqtSlot()
|
|
def check_login(self, data=None):
|
|
"""
|
|
Processes the initial connection and convert to a PJLink packet if valid initial connection
|
|
|
|
:param data: Optional data if called from another routine
|
|
"""
|
|
log.debug(f'({self.entry.name}) check_login(data="{data}")')
|
|
if data is None:
|
|
# Reconnected setup?
|
|
log.debug(f'({self.name}) check_login() Waiting for readyRead()')
|
|
_chk = self.waitForReadyRead(2000) # 2 seconds should be plenty of time
|
|
if not _chk:
|
|
# Possible timeout issue
|
|
log.error(f'({self.entry.name}) Socket timeout waiting for login')
|
|
self.change_status(E_SOCKET_TIMEOUT)
|
|
return
|
|
log.debug(f'({self.name}) check_login() Checking for data')
|
|
data = self.readLine(self.max_size)
|
|
if data is None:
|
|
log.warning(f'({self.entry.name}) read is None - socket error?')
|
|
return
|
|
elif len(data) < 8:
|
|
log.warning(f'({self.entry.name}) Not enough data read - skipping')
|
|
return
|
|
# Possibility of extraneous data on input when reading.
|
|
# Clean out extraneous characters in buffer.
|
|
_ = self.read(1024)
|
|
log.debug(f'({self.entry.name}) check_login() read "{data.strip()}"')
|
|
# At this point, we should only have the initial login prompt with
|
|
# possible authentication
|
|
# PJLink initial login will be:
|
|
# 'PJLink 0' - Unauthenticated login - no extra steps required.
|
|
# 'PJLink 1 XXXXXX' Authenticated login - extra processing required.
|
|
if type(data) is bytes:
|
|
data = decode(data, 'utf-8')
|
|
if not data.upper().startswith('PJLINK'):
|
|
# Invalid initial packet - close socket
|
|
log.error(f'({self.entry.name}) Invalid initial packet received - closing socket')
|
|
return self.disconnect_from_host()
|
|
# Convert the initial login prompt with the expected PJLink normal command format for processing
|
|
log.debug(f'({self.entry.name}) check_login(): Formatting initial connection prompt to PJLink packet')
|
|
return self.get_data(f'{PJLINK_PREFIX}1{data.replace(" ", "=", 1)}'.strip())
|
|
|
|
def _trash_buffer(self, msg=None):
|
|
"""
|
|
Clean out extraneous stuff in the buffer.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Cleaning buffer - msg = "{msg}"')
|
|
if msg is None:
|
|
msg = 'Invalid packet'
|
|
log.warning(f'({self.entry.name}) {msg}')
|
|
self.send_busy = False
|
|
trash_count = 0
|
|
while self.bytesAvailable() > 0:
|
|
trash = self.read(self.max_size)
|
|
trash_count += len(trash)
|
|
log.debug(f'({self.entry.name}) Finished cleaning buffer - {trash_count} bytes dropped')
|
|
return
|
|
|
|
@QtCore.pyqtSlot(QtNetwork.QHostAddress, int, str, name='udp_data') # host, port, data
|
|
def get_buffer(self, host, port, data):
|
|
"""
|
|
Get data from somewhere other than TCP socket
|
|
|
|
:param host: QHostAddress of sender
|
|
:param port: Destination port
|
|
:param data: Data to process. buffer must be formatted as a proper PJLink packet.
|
|
"""
|
|
if (port == int(self.port)) and (host.isEqual(self.qhost)):
|
|
log.debug(f'({self.entry.name}) Received data from {host.toString()}')
|
|
log.debug(f'({self.entry.name}) get_buffer(data="{data}")')
|
|
return self.get_data(buff=data)
|
|
else:
|
|
log.debug(f'({self.entry.name}) Ignoring data for {host.toString()} - not me')
|
|
|
|
@QtCore.pyqtSlot()
|
|
def get_socket(self):
|
|
"""
|
|
Get data from TCP socket.
|
|
"""
|
|
log.debug(f'({self.entry.name}) get_socket(): Reading data')
|
|
if QSOCKET_STATE[self.state()] != S_CONNECTED:
|
|
log.debug(f'({self.entry.name}) get_socket(): Not connected - returning')
|
|
self.send_busy = False
|
|
return
|
|
# Although we have a packet length limit, go ahead and use a larger buffer
|
|
self.socket_timer.start()
|
|
while self.bytesAvailable() >= 1:
|
|
data = self.readLine(1024)
|
|
data = data.strip()
|
|
if not data:
|
|
log.warning(f'({self.entry.name}) get_socket(): Ignoring empty packet')
|
|
if self.bytesAvailable() < 1:
|
|
break
|
|
|
|
self.socket_timer.stop()
|
|
if data:
|
|
log.debug(f'({self.entry.name}) get_socket(): "{data}"')
|
|
if data == -1:
|
|
# No data available
|
|
log.debug(f'({self.entry.namee}) get_socket(): No data available (-1)')
|
|
return
|
|
return self.get_data(buff=data)
|
|
|
|
def get_data(self, buff, *args, **kwargs):
|
|
"""
|
|
Process received data
|
|
|
|
:param buff: Data to process.
|
|
"""
|
|
log.debug(f'({self.entry.name}) get_data(buffer="{buff}"')
|
|
ignore_class = False if 'ignore_class' not in kwargs else kwargs['ignore_class']
|
|
log.debug(f'({self.entry.name}) Setting ignore_class to "{ignore_class}"')
|
|
# NOTE: Class2 has changed to some values being UTF-8
|
|
if type(buff) is bytes:
|
|
data = decode(buff, 'utf-8')
|
|
else:
|
|
data = buff
|
|
data = data.strip()
|
|
self.receive_data_signal()
|
|
# Initial packet checks
|
|
if len(data) < 7:
|
|
self._trash_buffer(msg='get_data(): Invalid packet - length')
|
|
return
|
|
elif len(data) > self.max_size:
|
|
self._trash_buffer(msg=f'get_data(): Invalid packet - too long ({len(data)} bytes)')
|
|
return
|
|
elif not data.startswith(PJLINK_PREFIX):
|
|
self._trash_buffer(msg='get_data(): Invalid packet - PJLink prefix missing')
|
|
return
|
|
elif data[6] != '=' and data[8] != '=':
|
|
# data[6] = standard command packet
|
|
# data[8] = initial PJLink connection (after mangling)
|
|
self._trash_buffer(msg='get_data(): Invalid reply - Does not have "="')
|
|
return
|
|
log.debug(f'({self.entry.name}) get_data(): Checking new data "{data}"')
|
|
header, data = data.split('=')
|
|
log.debug(f'({self.entry.name}) get_data() header="{header}" data="{data}"')
|
|
# At this point, the header should contain:
|
|
# "PVCCCC"
|
|
# Where:
|
|
# P = PJLINK_PREFIX
|
|
# V = PJLink class or version
|
|
# C = PJLink command
|
|
version, cmd = header[1], header[2:].upper()
|
|
log.debug(f'({self.entry.name}) get_data() version="{version}" cmd="{cmd}" data="{data}"')
|
|
if cmd not in PJLINK_VALID_CMD:
|
|
self._trash_buffer(msg=f'get_data(): Invalid packet - unknown command "{cmd}"')
|
|
return
|
|
elif version not in PJLINK_VALID_CMD[cmd]['version']:
|
|
self._trash_buffer(msg='get_data() Command reply version does not match a valid command version')
|
|
return
|
|
elif int(self.pjlink_class) < int(version):
|
|
log.warning(f'({self.entry.name}) get_data(): pjlink_class={self.pjlink_class} packet={version}')
|
|
if not ignore_class:
|
|
log.warning(f'({self.entry.name}) get_data(): Projector returned class reply higher '
|
|
'than projector stated class')
|
|
return
|
|
|
|
chk = process_command(self, cmd, data)
|
|
if chk is None:
|
|
# Command processed normally and not initial connection, so skip other checks
|
|
return
|
|
# PJLink initial connection checks
|
|
elif chk == S_DATA_OK:
|
|
# Previous command returned OK - resend command to retrieve status
|
|
log.debug(f'({self.entry.name}) OK returned - resending command')
|
|
self.send_command(cmd=cmd, priority=True)
|
|
elif chk == S_CONNECT:
|
|
# Normal connection
|
|
log.debug(f'({self.entry.name}) Connecting normal')
|
|
self.change_status(S_CONNECTED)
|
|
self.send_command(cmd='CLSS', priority=True)
|
|
self.readyRead.connect(self.get_socket)
|
|
elif chk == S_AUTHENTICATE:
|
|
# Connection with pin
|
|
log.debug(f'({self.entry.name}) Connecting with pin')
|
|
data_hash = str(qmd5_hash(salt=data.split()[1].encode('utf-8'),
|
|
data=self.pin.encode('utf-8')),
|
|
encoding='ascii')
|
|
self.change_status(S_CONNECTED)
|
|
self.readyRead.connect(self.get_socket)
|
|
self.send_command(cmd='CLSS', salt=data_hash, priority=True)
|
|
elif chk == E_AUTHENTICATION:
|
|
# Projector did not like our pin
|
|
log.warning(f'({self.entry.name}) Failed authentication - disconnecting')
|
|
self.disconnect_from_host()
|
|
self.projectorAuthentication.emit(self.entry.name)
|
|
self.change_status(status=E_AUTHENTICATION)
|
|
|
|
return
|
|
|
|
@QtCore.pyqtSlot(QtNetwork.QAbstractSocket.SocketError)
|
|
def get_error(self, err):
|
|
"""
|
|
Process error from SocketError signal.
|
|
Remaps system error codes to projector error codes.
|
|
|
|
:param err: Error code
|
|
"""
|
|
log.debug(f'({self.entry.name}) get_error(err={err}): {self.errorString()}')
|
|
if err <= 18:
|
|
# QSocket errors. Redefined in projector.constants so we don't mistake
|
|
# them for system errors
|
|
check = err + E_CONNECTION_REFUSED
|
|
self.poll_timer.stop()
|
|
else:
|
|
check = err
|
|
if check < E_GENERAL:
|
|
# Some system error?
|
|
self.change_status(err, self.errorString())
|
|
else:
|
|
self.change_status(E_NETWORK, self.errorString())
|
|
self.projectorUpdateIcons.emit()
|
|
if self.status_connect == E_NOT_CONNECTED:
|
|
self.abort()
|
|
self.reset_information()
|
|
return
|
|
|
|
def send_command(self, cmd, opts='?', salt=None, priority=False):
|
|
"""
|
|
Add command to output queue if not already in queue.
|
|
|
|
:param cmd: Command to send
|
|
:param opts: Command option (if any) - defaults to '?' (get information)
|
|
:param salt: Optional salt for md5 hash initial authentication
|
|
:param priority: Option to send packet now rather than queue it up
|
|
"""
|
|
log.debug(f'({self.entry.name}) send_command(cmd="{cmd}" opts="{opts}" salt="{salt}" priority={priority}')
|
|
if QSOCKET_STATE[self.state()] != S_CONNECTED:
|
|
log.warning(f'({self.entry.name}) send_command(): Not connected - returning')
|
|
return self.reset_information()
|
|
if cmd not in PJLINK_VALID_CMD:
|
|
log.error(f'({self.entry.name}) send_command(): Invalid command requested - ignoring.')
|
|
if self.priority_queue or self.send_queue:
|
|
# Just in case there's already something to send
|
|
return self._send_command()
|
|
return
|
|
log.debug(f'({self.entry.name}) send_command(): Building cmd="{cmd}" opts="{opts}" '
|
|
f'{"" if salt is None else "with hash"}')
|
|
# Until we absolutely have to start doing version checks, use the default
|
|
# for PJLink class
|
|
header = PJLINK_HEADER.format(linkclass=PJLINK_VALID_CMD[cmd]['default'])
|
|
out = f'{"" if salt is None else salt}{header}{cmd} {opts}{PJLINK_SUFFIX}'
|
|
if out in self.priority_queue:
|
|
log.warning(f'({self.entry.name}) send_command(): Already in priority queue - skipping')
|
|
elif out in self.send_queue:
|
|
log.warning(f'({self.entry.name}) send_command(): Already in normal queue - skipping')
|
|
else:
|
|
if priority:
|
|
log.debug(f'({self.entry.name}) send_command(): Adding to priority queue')
|
|
self.priority_queue.append(out)
|
|
else:
|
|
log.debug(f'({self.entry.name}) send_command(): Adding to normal queue')
|
|
self.send_queue.append(out)
|
|
if self.priority_queue or self.send_queue:
|
|
# May be some initial connection setup so make sure we send data
|
|
self._send_command()
|
|
|
|
@QtCore.pyqtSlot()
|
|
def _send_command(self, data=None, utf8=False):
|
|
"""
|
|
Socket interface to send data. If data=None, then check queue.
|
|
|
|
:param data: Immediate data to send (Optional)
|
|
:param utf8: Send as UTF-8 string otherwise send as ASCII string
|
|
"""
|
|
if not data and not self.priority_queue and not self.send_queue:
|
|
log.warning(f'({self.entry.name}) _send_command(): Nothing to send - returning')
|
|
self.send_busy = False
|
|
return
|
|
log.debug(f'({self.entry.name}) _send_command(data="{data if not data else data.strip()}")')
|
|
log.debug(f'({self.entry.name}) _send_command(): priority_queue: {self.priority_queue}')
|
|
log.debug(f'({self.entry.name}) _send_command(): send_queue: {self.send_queue}')
|
|
conn_state = STATUS_CODE[QSOCKET_STATE[self.state()]]
|
|
log.debug(f'({self.entry.name}) _send_command(): Connection status: {conn_state}')
|
|
if QSOCKET_STATE[self.state()] != S_CONNECTED:
|
|
log.warning(f'({self.entry.name}) _send_command() Not connected - abort')
|
|
self.send_busy = False
|
|
return self.disconnect_from_host()
|
|
if data and data not in self.priority_queue:
|
|
log.debug(f'({self.entry.name}) _send_command(): Priority packet - adding to priority queue')
|
|
self.priority_queue.append(data)
|
|
|
|
if self.send_busy:
|
|
# Still waiting for response from last command sent
|
|
log.debug(f'({self.entry.name}) _send_command(): Still busy, returning')
|
|
log.debug(f'({self.entry.name}) _send_command(): Priority queue = {self.priority_queue}')
|
|
log.debug(f'({self.entry.name}) _send_command(): Normal queue = {self.send_queue}')
|
|
return
|
|
|
|
if not self.priority_queue and not self.send_queue:
|
|
# No data to send
|
|
log.warning(f'({self.entry.name}) _send_command(): No data to send')
|
|
self.send_busy = False
|
|
return
|
|
elif self.priority_queue:
|
|
out = self.priority_queue.pop(0)
|
|
log.debug(f'({self.entry.name}) _send_command(): Getting priority queued packet')
|
|
elif self.send_queue:
|
|
out = self.send_queue.pop(0)
|
|
log.debug(f'({self.entry.name}) _send_command(): Getting normal queued packet')
|
|
self.send_busy = True
|
|
log.debug(f'({self.entry.name}) _send_command(): Sending "{out.strip()}"')
|
|
self.socket_timer.start()
|
|
sent = self.write(out.encode(f'{"utf-8" if utf8 else "ascii"}'))
|
|
self.waitForBytesWritten(2000) # 2 seconds should be enough
|
|
if sent == -1:
|
|
# Network error?
|
|
self.change_status(E_NETWORK,
|
|
translate('OpenLP.PJLink', 'Error while sending data to projector'))
|
|
log.warning(f'({self.entry.name}) _send_command(): -1 received - disconnecting from host')
|
|
self.disconnect_from_host()
|
|
|
|
def connect_to_host(self):
|
|
"""
|
|
Initiate connection to projector.
|
|
"""
|
|
log.debug(f'({self.entry.name}) connect_to_host(): Starting connection')
|
|
if QSOCKET_STATE[self.state()] == S_CONNECTED:
|
|
log.warning(f'({self.entry.name}) connect_to_host(): Already connected - returning')
|
|
return
|
|
self.error_status = S_OK
|
|
self.change_status(S_CONNECTING)
|
|
self.connectToHost(self.ip, self.port)
|
|
|
|
@QtCore.pyqtSlot()
|
|
def disconnect_from_host(self, abort=False):
|
|
"""
|
|
Close socket and cleanup.
|
|
"""
|
|
if abort or QSOCKET_STATE[self.state()] != S_NOT_CONNECTED:
|
|
if abort:
|
|
log.warning(f'({self.entry.name}) disconnect_from_host(): Aborting connection')
|
|
self.abort()
|
|
else:
|
|
log.warning(f'({self.entry.name}) disconnect_from_host(): Not connected')
|
|
try:
|
|
self.readyRead.disconnect(self.get_socket)
|
|
except TypeError:
|
|
# Since we already know what's happening, just log it for reference.
|
|
log.debug(f'({self.entry.name}) disconnect_from_host(): Issue detected with readyRead.disconnect')
|
|
log.debug(f'({self.entry.name}) disconnect_from_host(): '
|
|
f'Current status {self._get_status(self.status_connect)[0]}')
|
|
self.disconnectFromHost()
|
|
if abort:
|
|
self.change_status(E_NOT_CONNECTED)
|
|
else:
|
|
self.change_status(S_NOT_CONNECTED)
|
|
self.reset_information()
|
|
|
|
def get_av_mute_status(self, priority=False):
|
|
"""
|
|
Send command to retrieve shutter status.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending AVMT command')
|
|
return self.send_command(cmd='AVMT', priority=priority)
|
|
|
|
def get_available_inputs(self):
|
|
"""
|
|
Send command to retrieve available source inputs.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending INST command')
|
|
return self.send_command(cmd='INST')
|
|
|
|
def get_error_status(self):
|
|
"""
|
|
Send command to retrieve currently known errors.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending ERST command')
|
|
return self.send_command(cmd='ERST')
|
|
|
|
def get_input_source(self):
|
|
"""
|
|
Send command to retrieve currently selected source input.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending INPT command')
|
|
return self.send_command(cmd='INPT')
|
|
|
|
def get_lamp_status(self):
|
|
"""
|
|
Send command to return the lap status.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending LAMP command')
|
|
return self.send_command(cmd='LAMP')
|
|
|
|
def get_manufacturer(self):
|
|
"""
|
|
Send command to retrieve manufacturer name.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending INF1 command')
|
|
return self.send_command(cmd='INF1')
|
|
|
|
def get_model(self):
|
|
"""
|
|
Send command to retrieve the model name.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending INF2 command')
|
|
return self.send_command(cmd='INF2')
|
|
|
|
def get_name(self):
|
|
"""
|
|
Send command to retrieve name as set by end-user (if set).
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending NAME command')
|
|
return self.send_command(cmd='NAME')
|
|
|
|
def get_other_info(self):
|
|
"""
|
|
Send command to retrieve extra info set by manufacturer.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending INFO command')
|
|
return self.send_command(cmd='INFO')
|
|
|
|
def get_power_status(self, priority=False):
|
|
"""
|
|
Send command to retrieve power status.
|
|
|
|
:param priority: (OPTIONAL) Send in priority queue
|
|
"""
|
|
log.debug(f'({self.entry.name}) Sending POWR command')
|
|
return self.send_command(cmd='POWR', priority=priority)
|
|
|
|
def set_audio_mute(self, priority=False):
|
|
"""
|
|
Send command to set audio to muted
|
|
"""
|
|
log.debug(f'({self.entry.name}) Setting AVMT to 21 (audio mute)')
|
|
self.send_command(cmd='AVMT', opts='21', priority=True)
|
|
self.status_timer_add(cmd='AVMT', callback=self.get_av_mute_status)
|
|
self.poll_loop()
|
|
|
|
def set_audio_normal(self, priority=False):
|
|
"""
|
|
Send command to set audio to normal
|
|
"""
|
|
log.debug(f'({self.entry.name}) Setting AVMT to 20 (audio normal)')
|
|
self.send_command(cmd='AVMT', opts='20', priority=True)
|
|
self.status_timer_add(cmd='AVMT', callback=self.get_av_mute_status)
|
|
self.poll_loop()
|
|
|
|
def set_input_source(self, src=None):
|
|
"""
|
|
Verify input source available as listed in 'INST' command,
|
|
then send the command to select the input source.
|
|
|
|
:param src: Video source to select in projector
|
|
"""
|
|
log.debug(f'({self.entry.name}) set_input_source(src="{src}")')
|
|
if self.source_available is None:
|
|
return
|
|
elif src not in self.source_available:
|
|
return
|
|
log.debug(f'({self.entry.name}) Setting input source to "{src}"')
|
|
self.send_command(cmd='INPT', opts=src, priority=True)
|
|
self.poll_loop()
|
|
|
|
def set_power_on(self):
|
|
"""
|
|
Send command to turn power to on.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Setting POWR to 1 (on)')
|
|
self.send_command(cmd='POWR', opts='1', priority=True)
|
|
self.status_timer_add(cmd='POWR', callback=self.get_power_status)
|
|
self.poll_loop()
|
|
|
|
def set_power_off(self):
|
|
"""
|
|
Send command to turn power to standby.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Setting POWR to 0 (standby)')
|
|
self.send_command(cmd='POWR', opts='0', priority=True)
|
|
self.status_timer_add(cmd='POWR', callback=self.get_power_status)
|
|
self.poll_loop()
|
|
|
|
def set_shutter_closed(self):
|
|
"""
|
|
Send command to set shutter to closed position.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Setting AVMT to 11 (shutter closed)')
|
|
self.send_command(cmd='AVMT', opts='11', priority=True)
|
|
self.status_timer_add('AVMT', self.get_av_mute_status)
|
|
self.poll_loop()
|
|
|
|
def set_shutter_open(self):
|
|
"""
|
|
Send command to set shutter to open position.
|
|
"""
|
|
log.debug(f'({self.entry.name}) Setting AVMT to "10" (shutter open)')
|
|
self.send_command(cmd='AVMT', opts='10', priority=True)
|
|
self.status_timer_add('AVMT', self.get_av_mute_status)
|
|
self.poll_loop()
|
|
|
|
def status_timer_add(self, cmd, callback):
|
|
"""
|
|
Add a callback to the status timer.
|
|
|
|
:param cmd: PJLink command associated with callback
|
|
:param callback: Method to call
|
|
"""
|
|
if cmd in self.status_timer_checks:
|
|
log.warning(f'({self.entry.name}) "{cmd}" already in checks - returning')
|
|
return
|
|
log.debug(f'({self.entry.name}) Adding "{cmd}" callback for status timer')
|
|
self.status_timer_checks[cmd] = callback
|
|
if not self.status_timer.isActive():
|
|
self.status_timer.start()
|
|
|
|
def status_timer_delete(self, cmd):
|
|
"""
|
|
Delete a callback from the status timer.
|
|
|
|
:param cmd: PJLink command associated with callback
|
|
:param callback: Method to call
|
|
"""
|
|
if cmd not in self.status_timer_checks:
|
|
log.warning(f'({self.entry.name}) "{cmd}" not listed in status timer - returning')
|
|
return
|
|
log.debug(f'({self.entry.name}) Removing "{cmd}" from status timer')
|
|
self.status_timer_checks.pop(cmd)
|
|
if not self.status_timer_checks:
|
|
self.status_timer.stop()
|
|
|
|
def status_timer_update(self):
|
|
"""
|
|
Call methods defined in status_timer_checks for updates
|
|
"""
|
|
if not self.status_timer_checks:
|
|
log.warning(f'({self.entry.name}) status_timer_update() called when no callbacks - Race condition?')
|
|
self.status_timer.stop()
|
|
return
|
|
for cmd, callback in self.status_timer_checks.items():
|
|
log.debug(f'({self.entry.name}) Status update call for {cmd}')
|
|
callback(priority=True)
|
|
|
|
def receive_data_signal(self):
|
|
"""
|
|
Clear any busy flags and send data received signal
|
|
"""
|
|
self.send_busy = False
|
|
self.projectorReceivedData.emit()
|
|
return
|