diff --git a/aqualogic/core.py b/aqualogic/core.py index 311adc9..dc799d4 100644 --- a/aqualogic/core.py +++ b/aqualogic/core.py @@ -10,6 +10,7 @@ import socket import time import serial +import datetime _LOGGER = logging.getLogger(__name__) @@ -89,6 +90,8 @@ class AquaLogic(): FRAME_STX = 0x02 FRAME_ETX = 0x03 + READ_TIMEOUT = 5 + # Local wired panel (black face with service button) FRAME_TYPE_LOCAL_WIRED_KEY_EVENT = b'\x00\x02' # Remote wired panel (white face) @@ -104,9 +107,9 @@ class AquaLogic(): FRAME_TYPE_PUMP_SPEED_REQUEST = b'\x0c\x01' FRAME_TYPE_PUMP_STATUS = b'\x00\x0c' - def __init__(self, reader=None, writer=None): - self._reader = reader - self._writer = writer + def __init__(self): + self._socket = None + self._serial = None self._is_metric = False self._air_temp = None self._pool_temp = None @@ -128,16 +131,17 @@ def connect(self, host, port): def connect_socket(self, host, port): """Connects via a RS-485 to Ethernet adapter.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((host, port)) - self._reader = sock.makefile(mode='rb') - self._writer = sock.makefile(mode='wb') + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.connect((host, port)) + self._socket.settimeout(self.READ_TIMEOUT) + self._read = self._read_byte_from_socket + self._write = self._write_to_socket def connect_serial(self, serial_port_name): - s = serial.Serial(port=serial_port_name, baudrate=19200, - stopbits=serial.STOPBITS_TWO) - self._reader = s - self._writer = s + self._serial = serial.Serial(port=serial_port_name, baudrate=19200, + stopbits=serial.STOPBITS_TWO, timeout=self.READ_TIMEOUT) + self._read = self._read_byte_from_serial + self._write = self._write_to_serial def _check_state(self, data): desired_states = data['desired_states'] @@ -154,11 +158,27 @@ def _check_state(self, data): else: _LOGGER.debug('state change successful') + def _read_byte_from_socket(self): + data = self._socket.recv(1) + return data[0] + + def _read_byte_from_serial(self): + data = self._serial.read(1) + if len(data) == 0: + raise serial.SerialTimeoutException() + return data[0] + + def _write_to_socket(self, data): + self._socket.send(data) + + def _write_to_serial(self, data): + self._serial.send(data) + self._serial.flush() + def _send_frame(self): if not self._send_queue.empty(): data = self._send_queue.get(block=False) - self._writer.write(data['frame']) - self._writer.flush() + self._write(data['frame']) _LOGGER.info('%3.3f: Sent: %s', time.monotonic(), binascii.hexlify(data['frame'])) @@ -175,195 +195,198 @@ def process(self, data_changed_callback): """Process data; returns when the reader signals EOF. Callback is notified when any data changes.""" # pylint: disable=too-many-locals,too-many-branches,too-many-statements - while True: - # Data framing (from the AQ-CO-SERIAL manual): - # - # Each frame begins with a DLE (10H) and STX (02H) character start - # sequence, followed by a 2 to 61 byte long Command/Data field, a - # 2-byte Checksum and a DLE (10H) and ETX (03H) character end - # sequence. - # - # The DLE, STX and Command/Data fields are added together to - # provide the 2-byte Checksum. If any of the bytes of the - # Command/Data Field or Checksum are equal to the DLE character - # (10H), a NULL character (00H) is inserted into the transmitted - # data stream immediately after that byte. That NULL character - # must then be removed by the receiver. - - byte = self._reader.read(1) - frame_start_time = None - + try: while True: - # Search for FRAME_DLE + FRAME_STX - if not byte: - return - if byte[0] == self.FRAME_DLE: - frame_start_time = time.monotonic() - next_byte = self._reader.read(1) - if not next_byte: + # Data framing (from the AQ-CO-SERIAL manual): + # + # Each frame begins with a DLE (10H) and STX (02H) character start + # sequence, followed by a 2 to 61 byte long Command/Data field, a + # 2-byte Checksum and a DLE (10H) and ETX (03H) character end + # sequence. + # + # The DLE, STX and Command/Data fields are added together to + # provide the 2-byte Checksum. If any of the bytes of the + # Command/Data Field or Checksum are equal to the DLE character + # (10H), a NULL character (00H) is inserted into the transmitted + # data stream immediately after that byte. That NULL character + # must then be removed by the receiver. + + byte = self._read() + frame_start_time = None + + frame_rx_time = datetime.datetime.now() + + while True: + # Search for FRAME_DLE + FRAME_STX + if byte == self.FRAME_DLE: + frame_start_time = time.monotonic() + next_byte = self._read() + if next_byte == self.FRAME_STX: + break + else: + continue + byte = self._read() + elapsed = datetime.datetime.now() - frame_rx_time + if elapsed.seconds > self.READ_TIMEOUT: + _LOGGER.info('Frame timeout') return - if next_byte[0] == self.FRAME_STX: - break - else: - continue - byte = self._reader.read(1) - - frame = bytearray() - byte = self._reader.read(1) - while True: - if not byte: - return - if byte[0] == self.FRAME_DLE: - # Should be FRAME_ETX or 0 according to - # the AQ-CO-SERIAL manual - next_byte = self._reader.read(1) - if not next_byte: - return - if next_byte[0] == self.FRAME_ETX: - break - elif next_byte[0] != 0: - # Error? + frame = bytearray() + byte = self._read() + + while True: + if byte == self.FRAME_DLE: + # Should be FRAME_ETX or 0 according to + # the AQ-CO-SERIAL manual + next_byte = self._read() + if next_byte == self.FRAME_ETX: + break + elif next_byte != 0: + # Error? + pass + + frame.append(byte) + byte = self._read() + + # Verify CRC + frame_crc = int.from_bytes(frame[-2:], byteorder='big') + frame = frame[:-2] + + calculated_crc = self.FRAME_DLE + self.FRAME_STX + for byte in frame: + calculated_crc += byte + + if frame_crc != calculated_crc: + _LOGGER.warning('Bad CRC') + continue + + frame_type = frame[0:2] + frame = frame[2:] + + if frame_type == self.FRAME_TYPE_KEEP_ALIVE: + # Keep alive + # _LOGGER.debug('%3.3f: KA', frame_start_time) + + # If a frame has been queued for transmit, send it. + if not self._send_queue.empty(): + self._send_frame() + + continue + elif frame_type == self.FRAME_TYPE_LOCAL_WIRED_KEY_EVENT: + _LOGGER.debug('%3.3f: Local Wired Key: %s', + frame_start_time, binascii.hexlify(frame)) + elif frame_type == self.FRAME_TYPE_REMOTE_WIRED_KEY_EVENT: + _LOGGER.debug('%3.3f: Remote Wired Key: %s', + frame_start_time, binascii.hexlify(frame)) + elif frame_type == self.FRAME_TYPE_WIRELESS_KEY_EVENT: + _LOGGER.debug('%3.3f: Wireless Key: %s', + frame_start_time, binascii.hexlify(frame)) + elif frame_type == self.FRAME_TYPE_LEDS: + # _LOGGER.debug('%3.3f: LEDs: %s', + # frame_start_time, binascii.hexlify(frame)) + # First 4 bytes are the LEDs that are on; + # second 4 bytes_ are the LEDs that are flashing + states = int.from_bytes(frame[0:4], byteorder='little') + flashing_states = int.from_bytes(frame[4:8], + byteorder='little') + states |= flashing_states + if self._heater_auto_mode: + states |= States.HEATER_AUTO_MODE + if (states != self._states or + flashing_states != self._flashing_states): + self._states = states + self._flashing_states = flashing_states + data_changed_callback(self) + elif frame_type == self.FRAME_TYPE_PUMP_SPEED_REQUEST: + value = int.from_bytes(frame[0:2], byteorder='big') + _LOGGER.debug('%3.3f: Pump speed request: %d%%', + frame_start_time, value) + if self._pump_speed != value: + self._pump_speed = value + data_changed_callback(self) + elif ((frame_type == self.FRAME_TYPE_PUMP_STATUS) and + (len(frame) >= 5)): + # Pump status messages sent out by Hayward VSP pumps + self._multi_speed_pump = True + speed = frame[2] + # Power is in BCD + power = ((((frame[3] & 0xf0) >> 4) * 1000) + + (((frame[3] & 0x0f)) * 100) + + (((frame[4] & 0xf0) >> 4) * 10) + + (((frame[4] & 0x0f)))) + _LOGGER.debug('%3.3f; Pump speed: %d%%, power: %d watts', + frame_start_time, speed, power) + if self._pump_power != power: + self._pump_power = power + data_changed_callback(self) + elif frame_type == self.FRAME_TYPE_DISPLAY_UPDATE: + parts = frame.decode('latin-1').split() + _LOGGER.debug('%3.3f: Display update: %s', + frame_start_time, parts) + + try: + if parts[0] == 'Pool' and parts[1] == 'Temp': + # Pool Temp °[C|F] + value = int(parts[2][:-2]) + if self._pool_temp != value: + self._pool_temp = value + self._is_metric = parts[2][-1:] == 'C' + data_changed_callback(self) + elif parts[0] == 'Spa' and parts[1] == 'Temp': + # Spa Temp °[C|F] + value = int(parts[2][:-2]) + if self._spa_temp != value: + self._spa_temp = value + self._is_metric = parts[2][-1:] == 'C' + data_changed_callback(self) + elif parts[0] == 'Air' and parts[1] == 'Temp': + # Air Temp °[C|F] + value = int(parts[2][:-2]) + if self._air_temp != value: + self._air_temp = value + self._is_metric = parts[2][-1:] == 'C' + data_changed_callback(self) + elif parts[0] == 'Pool' and parts[1] == 'Chlorinator': + # Pool Chlorinator % + value = int(parts[2][:-1]) + if self._pool_chlorinator != value: + self._pool_chlorinator = value + data_changed_callback(self) + elif parts[0] == 'Spa' and parts[1] == 'Chlorinator': + # Spa Chlorinator % + value = int(parts[2][:-1]) + if self._spa_chlorinator != value: + self._spa_chlorinator = value + data_changed_callback(self) + elif parts[0] == 'Salt' and parts[1] == 'Level': + # Salt Level [g/L|PPM| + value = float(parts[2]) + if self._salt_level != value: + self._salt_level = value + self._is_metric = parts[3] == 'g/L' + data_changed_callback(self) + elif parts[0] == 'Check' and parts[1] == 'System': + # Check System + value = ' '.join(parts[2:]) + if self._check_system_msg != value: + self._check_system_msg = value + data_changed_callback(self) + elif parts[0] == 'Heater1': + self._heater_auto_mode = parts[1] == 'Auto' + except ValueError: pass - - frame.append(byte[0]) - byte = self._reader.read(1) - - # Verify CRC - frame_crc = int.from_bytes(frame[-2:], byteorder='big') - frame = frame[:-2] - - calculated_crc = self.FRAME_DLE + self.FRAME_STX - for byte in frame: - calculated_crc += byte - - if frame_crc != calculated_crc: - _LOGGER.warning('Bad CRC') - continue - - frame_type = frame[0:2] - frame = frame[2:] - - if frame_type == self.FRAME_TYPE_KEEP_ALIVE: - # Keep alive - # _LOGGER.debug('%3.3f: KA', frame_start_time) - - # If a frame has been queued for transmit, send it. - if not self._send_queue.empty(): - self._send_frame() - - continue - elif frame_type == self.FRAME_TYPE_LOCAL_WIRED_KEY_EVENT: - _LOGGER.debug('%3.3f: Local Wired Key: %s', - frame_start_time, binascii.hexlify(frame)) - elif frame_type == self.FRAME_TYPE_REMOTE_WIRED_KEY_EVENT: - _LOGGER.debug('%3.3f: Remote Wired Key: %s', - frame_start_time, binascii.hexlify(frame)) - elif frame_type == self.FRAME_TYPE_WIRELESS_KEY_EVENT: - _LOGGER.debug('%3.3f: Wireless Key: %s', - frame_start_time, binascii.hexlify(frame)) - elif frame_type == self.FRAME_TYPE_LEDS: - # _LOGGER.debug('%3.3f: LEDs: %s', - # frame_start_time, binascii.hexlify(frame)) - # First 4 bytes are the LEDs that are on; - # second 4 bytes_ are the LEDs that are flashing - states = int.from_bytes(frame[0:4], byteorder='little') - flashing_states = int.from_bytes(frame[4:8], - byteorder='little') - states |= flashing_states - if self._heater_auto_mode: - states |= States.HEATER_AUTO_MODE - if (states != self._states or - flashing_states != self._flashing_states): - self._states = states - self._flashing_states = flashing_states - data_changed_callback(self) - elif frame_type == self.FRAME_TYPE_PUMP_SPEED_REQUEST: - value = int.from_bytes(frame[0:2], byteorder='big') - _LOGGER.debug('%3.3f: Pump speed request: %d%%', - frame_start_time, value) - if self._pump_speed != value: - self._pump_speed = value - data_changed_callback(self) - elif ((frame_type == self.FRAME_TYPE_PUMP_STATUS) and - (len(frame) >= 5)): - # Pump status messages sent out by Hayward VSP pumps - self._multi_speed_pump = True - speed = frame[2] - # Power is in BCD - power = ((((frame[3] & 0xf0) >> 4) * 1000) + - (((frame[3] & 0x0f)) * 100) + - (((frame[4] & 0xf0) >> 4) * 10) + - (((frame[4] & 0x0f)))) - _LOGGER.debug('%3.3f; Pump speed: %d%%, power: %d watts', - frame_start_time, speed, power) - if self._pump_power != power: - self._pump_power = power - data_changed_callback(self) - elif frame_type == self.FRAME_TYPE_DISPLAY_UPDATE: - parts = frame.decode('latin-1').split() - _LOGGER.debug('%3.3f: Display update: %s', - frame_start_time, parts) - - try: - if parts[0] == 'Pool' and parts[1] == 'Temp': - # Pool Temp °[C|F] - value = int(parts[2][:-2]) - if self._pool_temp != value: - self._pool_temp = value - self._is_metric = parts[2][-1:] == 'C' - data_changed_callback(self) - elif parts[0] == 'Spa' and parts[1] == 'Temp': - # Spa Temp °[C|F] - value = int(parts[2][:-2]) - if self._spa_temp != value: - self._spa_temp = value - self._is_metric = parts[2][-1:] == 'C' - data_changed_callback(self) - elif parts[0] == 'Air' and parts[1] == 'Temp': - # Air Temp °[C|F] - value = int(parts[2][:-2]) - if self._air_temp != value: - self._air_temp = value - self._is_metric = parts[2][-1:] == 'C' - data_changed_callback(self) - elif parts[0] == 'Pool' and parts[1] == 'Chlorinator': - # Pool Chlorinator % - value = int(parts[2][:-1]) - if self._pool_chlorinator != value: - self._pool_chlorinator = value - data_changed_callback(self) - elif parts[0] == 'Spa' and parts[1] == 'Chlorinator': - # Spa Chlorinator % - value = int(parts[2][:-1]) - if self._spa_chlorinator != value: - self._spa_chlorinator = value - data_changed_callback(self) - elif parts[0] == 'Salt' and parts[1] == 'Level': - # Salt Level [g/L|PPM| - value = float(parts[2]) - if self._salt_level != value: - self._salt_level = value - self._is_metric = parts[3] == 'g/L' - data_changed_callback(self) - elif parts[0] == 'Check' and parts[1] == 'System': - # Check System - value = ' '.join(parts[2:]) - if self._check_system_msg != value: - self._check_system_msg = value - data_changed_callback(self) - elif parts[0] == 'Heater1': - self._heater_auto_mode = parts[1] == 'Auto' - except ValueError: + elif frame_type == self.FRAME_TYPE_LONG_DISPLAY_UPDATE: + # Not currently parsed pass - elif frame_type == self.FRAME_TYPE_LONG_DISPLAY_UPDATE: - # Not currently parsed - pass - else: - _LOGGER.info('%3.3f: Unknown frame: %s %s', - frame_start_time, - binascii.hexlify(frame_type), - binascii.hexlify(frame)) + else: + _LOGGER.info('%3.3f: Unknown frame: %s %s', + frame_start_time, + binascii.hexlify(frame_type), + binascii.hexlify(frame)) + except socket.timeout: + _LOGGER.info("socket timeout") + except serial.SerialTimeoutException: + _LOGGER.info("serial timeout") def _append_data(self, frame, data): for byte in data: @@ -470,6 +493,16 @@ def is_metric(self): are in Metric.""" return self._is_metric + @property + def is_heater_enabled(self): + """Returns True if HEATER_1 is on""" + return self.get_state(States.HEATER_1) + + @property + def is_super_chlorinate_enabled(self): + """Returns True if super chlorinate is on""" + return self.get_state(States.SUPER_CHLORINATE) + def states(self): """Returns a set containing the enabled states.""" state_list = [] diff --git a/setup.py b/setup.py index 2b7e020..31a8f5d 100755 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name = 'aqualogic', packages = ['aqualogic'], # this must be the same as the name above - version = '2.5', + version = '2.6', description = 'Library for interfacing with a Hayward/Goldline AquaLogic/ProLogic pool controller.', long_description = 'A python library to interface with Hayward/Goldline AquaLogic/ProLogic pool controllers. Note that the Goldline protocol uses RS-485 so a hardware interface that can provide the library with reader and writer file objects is required. The simplest solution for this is an RS-485 to Ethernet adapter connected via a socket.', author = 'Sean Wilson',