From fec18e2a131eaaf5129a5015343df8c96db85f3e Mon Sep 17 00:00:00 2001 From: Sean Wilson Date: Thu, 27 Sep 2018 17:39:12 -0400 Subject: [PATCH] - Add connect() for socket - Switch from zope to simple callback - De-lint --- aqualogic/cli.py | 42 +++++------- aqualogic/core.py | 165 ++++++++++++++++++++++++--------------------- setup.py | 7 +- tests/test_core.py | 10 ++- 4 files changed, 115 insertions(+), 109 deletions(-) diff --git a/aqualogic/cli.py b/aqualogic/cli.py index cb620c5..40668ee 100644 --- a/aqualogic/cli.py +++ b/aqualogic/cli.py @@ -1,44 +1,36 @@ """aqualogic command line test app.""" -from core import AquaLogic, Keys, States -import zope.event -import socket import threading import logging import sys +from core import AquaLogic, States logging.basicConfig(level=logging.INFO) PORT = 23 -def data_changed(aq): - print('Pool Temp: {}'.format(aq.pool_temp)) - print('Air Temp: {}'.format(aq.air_temp)) - print('Pump Speed: {}'.format(aq.pump_speed)) - print('Pump Power: {}'.format(aq.pump_power)) - print('States: {}'.format(aq.states())) - if aq.get_state(States.CHECK_SYSTEM): - print('Check System: {}'.format(aq.check_system_msg)) +def _data_changed(panel): + print('Pool Temp: {}'.format(panel.pool_temp)) + print('Air Temp: {}'.format(panel.air_temp)) + print('Pump Speed: {}'.format(panel.pump_speed)) + print('Pump Power: {}'.format(panel.pump_power)) + print('States: {}'.format(panel.states())) + if panel.get_state(States.CHECK_SYSTEM): + print('Check System: {}'.format(panel.check_system_msg)) +PANEL = AquaLogic() print('Connecting to {}:{}...'.format(sys.argv[1], PORT)) -s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -s.connect((sys.argv[1], PORT)) -reader = s.makefile(mode='rb') -writer = s.makefile(mode='wb') +PANEL.connect(sys.argv[1], PORT) print('Connected!') -aq = AquaLogic(reader, writer) - -zope.event.subscribers.append(data_changed) - -reader_thread = threading.Thread(target=aq.process) -reader_thread.start() +READER_THREAD = threading.Thread(target=PANEL.process, args=[_data_changed]) +READER_THREAD.start() while True: - line = input() + LINE = input() try: - state = States[line] - aq.set_state(state, not aq.get_state(state)) + STATE = States[LINE] + PANEL.set_state(STATE, not PANEL.get_state(STATE)) except KeyError: - print('Invalid key {}'.format(line)) + print('Invalid key {}'.format(LINE)) diff --git a/aqualogic/core.py b/aqualogic/core.py index 2ba512c..6dc4acb 100644 --- a/aqualogic/core.py +++ b/aqualogic/core.py @@ -5,9 +5,8 @@ from threading import Timer import binascii import logging -import zope.event -import time import queue +import socket _LOGGER = logging.getLogger(__name__) @@ -67,7 +66,9 @@ class Keys(IntEnum): FILTER = 0x8000 -class AquaLogic(object): +class AquaLogic(): + """Hayward/Goldline AquaLogic/ProLogic pool controller.""" + # pylint: disable=too-many-instance-attributes FRAME_DLE = 0x10 FRAME_STX = 0x02 FRAME_ETX = 0x03 @@ -81,7 +82,7 @@ class AquaLogic(object): FRAME_TYPE_PUMP_SPEED_REQUEST = b'\x0c\x01' FRAME_TYPE_PUMP_STATUS = b'\x00\x0c' - def __init__(self, reader, writer): + def __init__(self, reader=None, writer=None): self._reader = reader self._writer = writer self._is_metric = False @@ -99,10 +100,20 @@ def __init__(self, reader, writer): self._send_queue = queue.Queue() self._multi_speed_pump = False - def check_state(self, data): - desiredStates = data['desiredStates'] - for x in desiredStates: - if self.get_state(x['state']) != x['enabled']: + + def connect(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') + + + def _check_state(self, data): + desired_states = data['desired_states'] + for desired_state in desired_states: + if (self.get_state(desired_state['state']) != + desired_state['enabled']): # The state hasn't changed data['retries'] -= 1 if data['retries'] != 0: @@ -110,55 +121,58 @@ def check_state(self, data): self._send_queue.put(data) return - def process(self): - """Process data; returns when the reader signals EOF.""" + + 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: - b = self._reader.read(1) + byte = self._reader.read(1) while True: # Search for FRAME_DLE + FRAME_STX - if not b: + if not byte: return - if b[0] == self.FRAME_DLE: - next_b = self._reader.read(1) - if not next_b: + if byte[0] == self.FRAME_DLE: + next_byte = self._reader.read(1) + if not next_byte: return - if next_b[0] == self.FRAME_STX: + if next_byte[0] == self.FRAME_STX: break else: continue - b = self._reader.read(1) + byte = self._reader.read(1) frame = bytearray() - b = self._reader.read(1) + byte = self._reader.read(1) while True: - if not b: + if not byte: return - if b[0] == self.FRAME_DLE: + if byte[0] == self.FRAME_DLE: # Should be FRAME_ETX or 0 according to # the AQ-CO-SERIAL manual - next_b = self._reader.read(1) - if not next_b: + next_byte = self._reader.read(1) + if not next_byte: return - if next_b[0] == self.FRAME_ETX: + if next_byte[0] == self.FRAME_ETX: break - elif next_b[0] != 0: + elif next_byte[0] != 0: # Error? pass - frame.append(b[0]) - b = self._reader.read(1) + 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 b in frame: - calculated_crc += b + for byte in frame: + calculated_crc += byte - if (frame_crc != calculated_crc): + if frame_crc != calculated_crc: _LOGGER.warning('Bad CRC') continue @@ -175,11 +189,11 @@ def process(self): _LOGGER.info('Sent: %s', binascii.hexlify(data['frame'])) try: - if data['desiredStates'] is not None: + if data['desired_states'] is not None: # Set a timer to verify the state changes # Wait 2 seconds as it can take a while for # the state to change. - Timer(2.0, self.check_state, [data]).start() + Timer(2.0, self._check_state, [data]).start() except KeyError: pass @@ -189,7 +203,7 @@ def process(self): elif frame_type == self.FRAME_TYPE_LEDS: _LOGGER.debug('LEDs: %s', binascii.hexlify(frame)) # First 4 bytes are the LEDs that are on; - # second 4 bytes are the LEDs that are flashing + # 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') @@ -198,13 +212,13 @@ def process(self): or flashing_states != self._flashing_states): self._states = states self._flashing_states = flashing_states - zope.event.notify(self) + data_changed_callback(self) elif frame_type == self.FRAME_TYPE_PUMP_SPEED_REQUEST: value = int.from_bytes(frame[0:2], byteorder='big') _LOGGER.debug('Pump speed request: %d%%', value) if self._pump_speed != value: self._pump_speed = value - zope.event.notify(self) + data_changed_callback(self) elif frame_type == self.FRAME_TYPE_PUMP_STATUS: # Pump status messages sent out by Hayward VSP pumps self._multi_speed_pump = True @@ -218,7 +232,7 @@ def process(self): speed, power) if self._pump_power != power: self._pump_power = power - zope.event.notify(self) + data_changed_callback(self) elif frame_type == self.FRAME_TYPE_DISPLAY_UPDATE: parts = frame.decode('latin-1').split() _LOGGER.debug('Display update: %s', parts) @@ -230,46 +244,46 @@ def process(self): if self._pool_temp != value: self._pool_temp = value self._is_metric = parts[2][-1:] == 'C' - zope.event.notify(self) + 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' - zope.event.notify(self) + 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' - zope.event.notify(self) + 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 - zope.event.notify(self) + 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 - zope.event.notify(self) + 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' - zope.event.notify(self) + 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 - zope.event.notify(self) + data_changed_callback(self) except ValueError: pass else: @@ -277,25 +291,25 @@ def process(self): binascii.hexlify(frame_type), binascii.hexlify(frame)) - def append_data(self, frame, data): - for c in data: - frame.append(c) - if c == self.FRAME_DLE: + def _append_data(self, frame, data): + for byte in data: + frame.append(byte) + if byte == self.FRAME_DLE: frame.append(0) - def get_key_event_frame(self, key): + def _get_key_event_frame(self, key): frame = bytearray() frame.append(self.FRAME_DLE) frame.append(self.FRAME_STX) - self.append_data(frame, self.FRAME_TYPE_KEY_EVENT) - self.append_data(frame, key.value.to_bytes(2, byteorder='big')) - self.append_data(frame, key.value.to_bytes(2, byteorder='big')) + self._append_data(frame, self.FRAME_TYPE_KEY_EVENT) + self._append_data(frame, key.value.to_bytes(2, byteorder='big')) + self._append_data(frame, key.value.to_bytes(2, byteorder='big')) crc = 0 - for b in frame: - crc += b - self.append_data(frame, crc.to_bytes(2, byteorder='big')) + for byte in frame: + crc += byte + self._append_data(frame, crc.to_bytes(2, byteorder='big')) frame.append(self.FRAME_DLE) frame.append(self.FRAME_ETX) @@ -305,7 +319,7 @@ def get_key_event_frame(self, key): def send_key(self, key): """Sends a key.""" _LOGGER.info('Queueing key %s', key) - frame = self.get_key_event_frame(key) + frame = self._get_key_event_frame(key) # Queue it to send immediately following the reception # of a keep-alive packet in an attempt to avoid bus collisions. @@ -348,16 +362,14 @@ def check_system_msg(self): """Returns the current 'Check System' message, or None if unknown.""" if self.get_state(States.CHECK_SYSTEM): return self._check_system_msg - else: - return None + return None @property def status(self): """Returns 'OK' or the current 'Check System' message.""" if self.get_state(States.CHECK_SYSTEM): return self._check_system_msg - else: - return 'OK' + return 'OK' @property def pump_speed(self): @@ -378,40 +390,39 @@ def is_metric(self): return self._is_metric def states(self): - list = [] """Returns a set containing the enabled states.""" - for e in States: - if e.value & self._states != 0: - list.append(e) + state_list = [] + for state in States: + if state.value & self._states != 0: + state_list.append(state) if (self._flashing_states & States.FILTER) != 0: - list.append(States.FILTER_LOW_SPEED) + state_list.append(States.FILTER_LOW_SPEED) - return list + return state_list def get_state(self, state): """Returns True if the specified state is enabled.""" # Check to see if we have a change request pending; if we do # return the value we expect it to change to. for data in list(self._send_queue.queue): - desiredStates = data['desiredStates'] - for x in desiredStates: - if x['state'] == state: - return x['enabled'] + desired_states = data['desired_states'] + for desired_state in desired_states: + if desired_state['state'] == state: + return desired_state['enabled'] if state == States.FILTER_LOW_SPEED: return (States.FILTER.value & self._flashing_states) != 0 - else: - return (state.value & self._states) != 0 + return (state.value & self._states) != 0 def set_state(self, state, enable): """Set the state.""" - isEnabled = self.get_state(state) - if isEnabled == enable: + is_enabled = self.get_state(state) + if is_enabled == enable: return True key = None - desiredStates = [{'state': state, 'enabled': not isEnabled}] + desired_states = [{'state': state, 'enabled': not is_enabled}] if state == States.FILTER_LOW_SPEED: if not self._multi_speed_pump: @@ -424,7 +435,7 @@ def set_state(self, state, enable): # the retry mechanism will send an additional FILTER key # to switch into high speed. key = Keys.FILTER - desiredStates.append({'state': States.FILTER, 'enabled': True}) + desired_states.append({'state': States.FILTER, 'enabled': True}) else: # See if this state has a corresponding Key try: @@ -434,12 +445,12 @@ def set_state(self, state, enable): # to enable the state return False - frame = self.get_key_event_frame(key) + frame = self._get_key_event_frame(key) # Queue it to send immediately following the reception # of a keep-alive packet in an attempt to avoid bus collisions. - self._send_queue.put({'frame': frame, 'desiredStates': desiredStates, - 'retries': 10}) + self._send_queue.put({'frame': frame, 'desired_states': desired_states, + 'retries': 10}) return True diff --git a/setup.py b/setup.py index dd0aca6..5087000 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 = '0.11', + version = '1.0', 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', @@ -14,7 +14,7 @@ # 3 - Alpha # 4 - Beta # 5 - Production/Stable - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', # Indicate who your project is intended for 'Intended Audience :: Developers', @@ -26,6 +26,5 @@ 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', - ], - install_requires=['zope.event>=4.3'] + ] ) diff --git a/tests/test_core.py b/tests/test_core.py index feb44b2..92c3da5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,10 +8,13 @@ logging.basicConfig(level=logging.DEBUG) class TestAquaLogic(object): + def data_changed(self, aq): + pass + def test_pool(self): reader = FileIO('tests/data/pool_on.bin') aq = AquaLogic(reader, None) - aq.process() + aq.process(self.data_changed) # Yes it was cold out when I grabbed this data assert aq.is_metric assert aq.air_temp == -6 @@ -23,11 +26,12 @@ def test_pool(self): assert aq.get_state(States.POOL) assert aq.get_state(States.FILTER) assert not aq.get_state(States.SPA) - + + def test_spa(self): reader = FileIO('tests/data/spa_on.bin') aq = AquaLogic(reader, None) - aq.process() + aq.process(self.data_changed) assert aq.is_metric assert aq.air_temp == -6 assert aq.pool_temp == None