├── .gitignore ├── README.md ├── pypentair ├── __init__.py ├── packet.py └── pump.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_pentair.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python SDK for Pentair Pool Pumps 2 | 3 | ## Errors 4 | 5 | |Error|Number| 6 | |-|-| 7 | |Messing up setting time|1| 8 | |Sending H, M, and S to the time setter that only understand H, M|7| 9 | |Sending a get or set with no parameters|8| 10 | |Setting a program RPM lower than MIN_RPM|10| 11 | |Setting Program 5 to Manual (which is not allowed per the docs)|10| 12 | |Setting Program 5 to Egg Timer (which is not allowed per the docs)|10| 13 | |Setting Program 1 to Disabled (which is not allowed per the docs)|10| 14 | |Scheduling a time that isn't a real time|10| 15 | |Schedule start time after playing with running schedules|25| 16 | 17 | ## Devices 18 | 19 | |Device|Address| 20 | |-|-| 21 | |BROADCAST|0x0F| 22 | |SUNTOUCH|0x10| 23 | |EASYTOUCH|0x20| 24 | |REMOTE_CONTROLLER|0x21| 25 | |REMOTE_WIRELESS_CONTROLLER|0x22| 26 | |QUICKTOUCH|0x48| 27 | |INTELLIFLO_PUMP_1|0x60| 28 | |INTELLIFLO_PUMP_2|0x61| 29 | |INTELLIFLO_PUMP_3|0x62| 30 | |INTELLIFLO_PUMP_4|0x63| 31 | |INTELLIFLO_PUMP_5|0x64| 32 | |INTELLIFLO_PUMP_6|0x65| 33 | |INTELLIFLO_PUMP_7|0x66| 34 | |INTELLIFLO_PUMP_8|0x67| 35 | |INTELLIFLO_PUMP_9|0x68| 36 | |INTELLIFLO_PUMP_10|0x69| 37 | |INTELLIFLO_PUMP_11|0x6A| 38 | |INTELLIFLO_PUMP_12|0x6B| 39 | |INTELLIFLO_PUMP_13|0x6C| 40 | |INTELLIFLO_PUMP_14|0x6D| 41 | |INTELLIFLO_PUMP_15|0x6E| 42 | |INTELLIFLO_PUMP_16|0x6F| 43 | 44 | ## Status Fields 45 | 46 | |Device|Field|Address| 47 | |-|-|-| 48 | |Pump|RUN|0| 49 | |Pump|MODE|1| 50 | |Pump|DRIVE_STATE|2| 51 | |Pump|WATTS_H|3| 52 | |Pump|WATTS_L|4| 53 | |Pump|RPM_H|5| 54 | |Pump|RPM_L|6| 55 | |Pump|GPM|7| 56 | |Pump|PPC|8| 57 | |Pump|UNKNOWN|9| 58 | |Pump|ERROR|10| 59 | |Pump|REMAINING_TIME_H|11| 60 | |Pump|REMAINING_TIME_M|12| 61 | |Pump|CLOCK_TIME_H|13| 62 | |Pump|CLOCK_TIME_M|14| 63 | 64 | ## Actions 65 | 66 | |Device|Action|Address|Notes| 67 | |-|-|-|-| 68 | |Pump|PING|0x00| 69 | |Pump|SET|0x01| 70 | |Pump|GET|0x02| 71 | |Pump|GET_TIME|0x03| 72 | |Pump|REMOTE_CONTROL|0x04| 73 | |Pump|PUMP_PROGRAM|0x05| 74 | |Pump|PUMP_POWER|0x06| 75 | |Pump|PUMP_STATUS|0x07| 76 | |Pump|SET_DATETIME|0x85|Need to figure out how these align with BROADCAST_ACTIONS, GET, and SET| 77 | |Pump|GET_DATETIME|0xC5| 78 | |Pump|GET_PUMP_STATUS|0xC7| 79 | |Pump|GET_SCHEDULE_DETAILS|0xD1| 80 | |Pump|GET_PUMP_CONFIG|0xD8| 81 | |Pump|ERROR|0xFF| 82 | 83 | ## Settings 84 | 85 | |Device|Scope|Setting|Address| 86 | |-|-|-|-| 87 | |Pump|Program 1|MODE|[0x03, 0x85]| 88 | |Pump|Program 2|MODE|[0x03, 0x86]| 89 | |Pump|Program 3|MODE|[0x03, 0x87]| 90 | |Pump|Program 4|MODE|[0x03, 0x88]| 91 | |Pump|Program 5|MODE|[0x03, 0x89]| 92 | |Pump|Program 6|MODE|[0x03, 0x8A]| 93 | |Pump|Program 7|MODE|[0x03, 0x8B]| 94 | |Pump|Program 8|MODE|[0x03, 0x8C]| 95 | |Pump|Program 1|RPM|[0x03, 0x8D]| 96 | |Pump|Program 2|RPM|[0x03, 0x8E]| 97 | |Pump|Program 3|RPM|[0x03, 0x8F]| 98 | |Pump|Program 4|RPM|[0x03, 0x90]| 99 | |Pump|Program 5|RPM|[0x03, 0x91]| 100 | |Pump|Program 6|RPM|[0x03, 0x92]| 101 | |Pump|Program 7|RPM|[0x03, 0x93]| 102 | |Pump|Program 8|RPM|[0x03, 0x94]| 103 | |Pump|Program 1|SCHEDULE_START|[0x03, 0x95]| 104 | |Pump|Program 2|SCHEDULE_START|[0x03, 0x96]| 105 | |Pump|Program 3|SCHEDULE_START|[0x03, 0x97]| 106 | |Pump|Program 4|SCHEDULE_START|[0x03, 0x98]| 107 | |Pump|Program 5|SCHEDULE_START|[0x03, 0x99]| 108 | |Pump|Program 6|SCHEDULE_START|[0x03, 0x9A]| 109 | |Pump|Program 7|SCHEDULE_START|[0x03, 0x9B]| 110 | |Pump|Program 8|SCHEDULE_START|[0x03, 0x9C]| 111 | |Pump|Program 1|SCHEDULE_END|[0x03, 0x9D]| 112 | |Pump|Program 2|SCHEDULE_END|[0x03, 0x9E]| 113 | |Pump|Program 3|SCHEDULE_END|[0x03, 0x9F]| 114 | |Pump|Program 4|SCHEDULE_END|[0x03, 0xA0]| 115 | |Pump|Program 5|SCHEDULE_END|[0x03, 0xA1]| 116 | |Pump|Program 6|SCHEDULE_END|[0x03, 0xA2]| 117 | |Pump|Program 7|SCHEDULE_END|[0x03, 0xA3]| 118 | |Pump|Program 8|SCHEDULE_END|[0x03, 0xA4]| 119 | |Pump|Program 1|EGG_TIMER|[0x03, 0xA5]| 120 | |Pump|Program 2|EGG_TIMER|[0x03, 0xA6]| 121 | |Pump|Program 3|EGG_TIMER|[0x03, 0xA7]| 122 | |Pump|Program 4|EGG_TIMER|[0x03, 0xA8]| 123 | |Pump|Program 5|EGG_TIMER|[0x03, 0xA9]| 124 | |Pump|Program 6|EGG_TIMER|[0x03, 0xAA]| 125 | |Pump|Program 7|EGG_TIMER|[0x03, 0xAB]| 126 | |Pump|Program 8|EGG_TIMER|[0x03, 0xAC]| 127 | -------------------------------------------------------------------------------- /pypentair/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import binascii 4 | 5 | from .pump import Pump 6 | 7 | RAISE_PACKET_ERRORS = False 8 | INSPECT_STATUS = False 9 | 10 | ADDRESSES = { 11 | 'BROADCAST': 0x0F, 12 | 'SUNTOUCH': 0x10, 13 | 'EASYTOUCH': 0x20, 14 | 'REMOTE_CONTROLLER': 0x21, 15 | 'REMOTE_WIRELESS_CONTROLLER': 0x22, 16 | 'QUICKTOUCH': 0x48, 17 | 'INTELLIFLO_PUMP_1': 0x60, 18 | 'INTELLIFLO_PUMP_2': 0x61, 19 | 'INTELLIFLO_PUMP_3': 0x62, 20 | 'INTELLIFLO_PUMP_4': 0x63, 21 | 'INTELLIFLO_PUMP_5': 0x64, 22 | 'INTELLIFLO_PUMP_6': 0x65, 23 | 'INTELLIFLO_PUMP_7': 0x66, 24 | 'INTELLIFLO_PUMP_8': 0x67, 25 | 'INTELLIFLO_PUMP_9': 0x68, 26 | 'INTELLIFLO_PUMP_10': 0x69, 27 | 'INTELLIFLO_PUMP_11': 0x6A, 28 | 'INTELLIFLO_PUMP_12': 0x6B, 29 | 'INTELLIFLO_PUMP_13': 0x6C, 30 | 'INTELLIFLO_PUMP_14': 0x6D, 31 | 'INTELLIFLO_PUMP_15': 0x6E, 32 | 'INTELLIFLO_PUMP_16': 0x6F 33 | } 34 | 35 | BROADCAST_ACTIONS = { 36 | 'ACK_MESSAGE': 0x01, 37 | 38 | 'CONTROLLER_STATUS': 0x02, 39 | 'DELAY_CANCEL': 0x03, 40 | 'DATE_TIME': 0x05, 41 | 'PUMP_STATUS': 0x07, 42 | 'HEATER_TEMPERATURE_STATUS': 0x08, 43 | 'CUSTOM_NAMES': 0x0A, 44 | 'CIRCUIT_NAMES': 0x0B, 45 | 'HEATER_PUMP_STATUS': 0x10, 46 | 'SCHEDULE_DETAILS': 0x11, 47 | 'INTELLICHEM': 0x12, 48 | 'INTELLIFLO_SPA_SIDE_CONTROL': 0x16, 49 | 'PUMP_STATUS_2': 0x17, # Differentation with 0x07? 50 | 'PUMP_CONFIG': 0x18, 51 | 'INTELLICHLOR_STATUS': 0x19, 52 | 'PUMP_CONFIG_EXTENDED': 0x1B, 53 | 'VALVE_STATUS': 0x1D, 54 | 'HIGH_SPEED_VALVE_CIRCUITS': 0x1E, 55 | 'IS4_IS10': 0x20, 56 | 'INTELLIFLO_SPA_SIDE_REMOTE': 0x21, 57 | 'HEATER_PUMP_STATUS_2': 0x22, # Differentiation with 0x10? 58 | 'DELAY_STATUS': 0x23, 59 | 'LIGHT_GROUPS': 0x27, 60 | 'HEAT_SETTINGS': 0x28, 61 | 62 | 'SET_COLOR': 0x60, 63 | } 64 | 65 | # For STATUS (0x02) through HEAT_SETTINGS (0x28): 66 | # - Add 0x80 for Setter 67 | # SET = 0x80 68 | # - Add 0xC0 for Getter 69 | # GET = 0xC0 70 | 71 | # Programs 1-4 can be programmed in all three modes. 72 | # Programs 5-8 can only be programmed in Schedule 73 | # mode since there are no buttons on the control panel 74 | # for Programs 5-8. The default setting for Programs 5-8 75 | # is "Disabled". 76 | # 77 | # - Manual 78 | # - Set Type 79 | # - Set Speed 80 | # - Set Flow 81 | # - Schedule 82 | # - Set Type 83 | # - Set Speed 84 | # - Set Flow 85 | # - Set Start Time 86 | # - Set Stop Time 87 | # - Egg Timer 88 | # - Set Type 89 | # - Set Speed 90 | # - Set Flow 91 | # - Set Duration 92 | 93 | PUMP_POWER = { 94 | False: 0x04, 95 | True: 0x0A, 96 | } 97 | 98 | REMOTE_CONTROL_MODES = { 99 | False: 0x00, 100 | True: 0xff 101 | } 102 | 103 | # SCHEDULE_DAYS are WEEKDAYS + 128 as the most significant bit of the mask 104 | # is always high 105 | 106 | 107 | def lookup(dict, val): # Dictionary inversion 108 | try: 109 | return list(dict.keys())[list(dict.values()).index(val)] 110 | except: 111 | return val 112 | 113 | 114 | def pp(prop): 115 | return binascii.hexlify(bytearray([prop])) 116 | -------------------------------------------------------------------------------- /pypentair/packet.py: -------------------------------------------------------------------------------- 1 | import serial 2 | 3 | DEBUG = False 4 | 5 | ERROR = 0xFF 6 | 7 | 8 | class Style(): 9 | HEADER = '\033[95m' 10 | OKBLUE = '\033[94m' 11 | OKGREEN = '\033[92m' 12 | WARNING = '\033[93m' 13 | FAIL = '\033[91m' 14 | ENDC = '\033[0m' 15 | BOLD = '\033[1m' 16 | UNDERLINE = '\033[4m' 17 | 18 | 19 | class Fields(): 20 | PREAMBLE_0 = 0 21 | PREAMBLE_1 = 1 22 | PREAMBLE_2 = 2 23 | HEADER = 3 24 | VERSION = 4 25 | DST = 5 26 | SRC = 6 27 | ACTION = 7 28 | DATA_LENGTH = 8 29 | DATA = 9 30 | 31 | 32 | class RS485(serial.Serial): 33 | def __init__(self): 34 | super().__init__(port='/dev/ttyUSB0', 35 | baudrate=9600, 36 | parity=serial.PARITY_NONE, 37 | stopbits=serial.STOPBITS_ONE, 38 | bytesize=serial.EIGHTBITS, 39 | timeout=1) 40 | 41 | def get_response(self): 42 | pbytes = [] 43 | while True: 44 | for c in self.read(): 45 | pbytes.append(c) 46 | if len(pbytes) > 4: 47 | pbytes.pop(0) 48 | if pbytes == Packet.PREAMBLE + [Packet.HEADER]: 49 | pbytes.extend(list(self.read(4))) # Version, DST, SRC, Action 50 | data_length = ord(self.read()) 51 | pbytes.append(data_length) 52 | pbytes.extend(list(self.read(data_length))) # Data 53 | pbytes.extend(list(self.read(2))) # Checksum 54 | return Packet(pbytes) 55 | 56 | 57 | rs485 = RS485() 58 | 59 | 60 | class Packet(): 61 | PREAMBLE = [0xFF, 0x00, 0xFF] 62 | HEADER = 0xA5 63 | VERSION = 0x00 64 | 65 | def __init__(self, *args, src=0x21, dst=None, action=None, data=None): 66 | if args != (): 67 | self.bytes = args 68 | else: 69 | self.dst = dst 70 | self.action = action 71 | self.src = src 72 | if isinstance(data, int): 73 | self.data = [data] 74 | else: 75 | self.data = data 76 | 77 | def send(self): 78 | rs485.write(bytearray(self.bytes)) 79 | if DEBUG: 80 | print() 81 | if DEBUG: 82 | print(Style.OKGREEN + "Request: ", self.bytes, Style.ENDC) 83 | response = rs485.get_response() 84 | if DEBUG: 85 | print(Style.OKBLUE + "Response:", response.bytes, Style.ENDC) 86 | if response.action == self.action: 87 | return response 88 | elif response.action == ERROR: 89 | if DEBUG: 90 | print(Style.FAIL, "ERROR:", response.bytes[9], Style.ENDC) 91 | return response 92 | else: 93 | raise ValueError("This packet goes somewhere else =(") 94 | 95 | @property 96 | def bytes(self): 97 | return Packet.PREAMBLE + self.payload + self.checkbytes 98 | 99 | @bytes.setter 100 | def bytes(self, array): 101 | packet = array[0] 102 | if packet[0:5] != Packet.PREAMBLE + [Packet.HEADER] + [Packet.VERSION]: 103 | packet = Packet.PREAMBLE + [Packet.HEADER] + [Packet.VERSION] + packet 104 | 105 | payload_start = Fields.HEADER 106 | data_length = packet[Fields.DATA_LENGTH] 107 | data_end = payload_start + data_length + Fields.DATA - Fields.HEADER 108 | packet_length = len(packet) 109 | 110 | payload = packet[payload_start:data_end] 111 | 112 | if packet_length > data_length + Fields.DATA: 113 | read_checksum = 256 * packet[-2] + packet[-1] 114 | if read_checksum != sum(payload): 115 | raise ValueError("Provided checksum does not match calculated checksum") 116 | return False 117 | 118 | self.dst = packet[Fields.DST] 119 | self.src = packet[Fields.SRC] 120 | self.action = packet[Fields.ACTION] 121 | 122 | if data_end > Fields.DATA: 123 | self.data = packet[Fields.DATA:data_end] 124 | else: 125 | self.data = None 126 | 127 | return self.bytes 128 | 129 | @property 130 | def checksum(self): 131 | return sum(self.payload) 132 | 133 | @property 134 | def checkbytes(self): 135 | return list(self.checksum.to_bytes(2, byteorder='big')) 136 | 137 | @property 138 | def data_length(self): 139 | if self.data: 140 | return len(self.data) 141 | else: 142 | return 0 143 | 144 | @property 145 | def to_int(self): 146 | return(self.data[0] << 8 | self.data[1]) 147 | 148 | @property 149 | def payload(self): 150 | if self.data_length: 151 | return [Packet.HEADER, Packet.VERSION, self.dst, self.src, self.action, self.data_length] + self.data 152 | else: 153 | return [Packet.HEADER, Packet.VERSION, self.dst, self.src, self.action, self.data_length] 154 | 155 | # def inspect(self): 156 | # print(" Destination:\t\t", pp(self.dst), lookup(ADDRESSES, self.dst)) 157 | # print(" Source:\t\t", pp(self.src), lookup(ADDRESSES, self.src)) 158 | # if self.dst == ADDRESSES['BROADCAST']: 159 | # print(" Action:\t\t", pp(self.action), lookup(BROADCAST_ACTIONS, self.action)) 160 | # else: 161 | # print(" Action:\t\t", pp(self.action), lookup(ACTIONS, self.action)) 162 | # print(" Data Length:\t\t", pp(self.data_length)) 163 | # if self.data_length > 0: 164 | # print(" Data:\t\t", binascii.hexlify(bytearray(self.data))) 165 | # 166 | # if self.action == ACTIONS['PUMP_STATUS'] and self.data_length > 0: 167 | # data = self.data 168 | # # print(data) 169 | # run = pp(data[PUMP_STATUS_FIELDS['RUN]) 170 | # print(" Run:\t\t", run, "ON" if run == "0a" else "OFF" if run == "04" else "") 171 | # print(" Mode:\t\t", pp(data[PUMP_STATUS_FIELDS['MODE])) 172 | # print(" Drive State:\t", pp(data[PUMP_STATUS_FIELDS['DRIVE_STATE])) 173 | # watts_h = data[PUMP_STATUS_FIELDS['WATTS_H] 174 | # watts_l = data[PUMP_STATUS_FIELDS['WATTS_L] 175 | # print(" Watts_H:\t\t", pp(data[PUMP_STATUS_FIELDS['WATTS_H])) 176 | # print(" Watts_L:\t\t", pp(data[PUMP_STATUS_FIELDS['WATTS_L])) 177 | # print(" Watts:\t\t\t", watts_h*0x100+watts_l) 178 | # rpm_h = data[PUMP_STATUS_FIELDS['RPM_H] 179 | # rpm_l = data[PUMP_STATUS_FIELDS['RPM_L] 180 | # print(" RPM_H:\t\t", pp(data[PUMP_STATUS_FIELDS['RPM_H])) 181 | # print(" RPM_L:\t\t", pp(data[PUMP_STATUS_FIELDS['RPM_L])) 182 | # print(" RPM:\t\t\t", rpm_h*0x100+rpm_l) 183 | # print(" REMAINING_TIME_H:\t", data[PUMP_STATUS_FIELDS['REMAINING_TIME_H]) 184 | # print(" REMAINING_TIME_M:\t", data[PUMP_STATUS_FIELDS['REMAINING_TIME_M]) 185 | # print(" CLOCK_TIME_H:\t", data[PUMP_STATUS_FIELDS['CLOCK_TIME_H]) 186 | # print(" CLOCK_TIME_M:\t", data[PUMP_STATUS_FIELDS['CLOCK_TIME_M]) 187 | -------------------------------------------------------------------------------- /pypentair/pump.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from time import sleep 4 | 5 | from .packet import Packet, Style 6 | 7 | ACTIONS = { 8 | '__0x08__': 0x08, 9 | '__0x09__': 0x09, 10 | '__0x0A__': 0x0A, 11 | 'SET_DATETIME': 0x85, # Need to figure out how these align with the BROADCAST_ACTIONS, GET, and SET 12 | 'GET_DATETIME': 0xC5, 13 | 'GET_PUMP_STATUS': 0xC7, 14 | 'GET_SCHEDULE_DETAILS': 0xD1, 15 | 'GET_PUMP_CONFIG': 0xD8, 16 | 'ERROR': 0xFF, 17 | } 18 | 19 | SETTING = { 20 | '__0x01, 0xC4__': [0x01, 0xC4], # 100 21 | '__0x01, 0xFE__': [0x01, 0xFE], # Changes often, generally in increments of 0x40. 22 | 23 | 'ACTUAL_RPM': [0x02, 0x06], 24 | '__0x02, 0x0A__': [0x02, 0x0A], # Always just a bit lower than Watts from PUMP_STATUS 25 | '__0x02, 0x1A__': [0x02, 0x1A], # [0x00, 0x00] to [0x51, 0x07] on SVRS alarm, back to 0 on reprime 26 | 'SVRS_ALARM': [0x02, 0x1C], # [0x00, 0x00] to [0xff, 0xff] on SVRS alarm, back to 0 on reprime 27 | 'CONTRAST': [0x02, 0xBD], 28 | 'ADDRESS': [0x02, 0xC0], 29 | 'TARGET_RPM': [0x02, 0xC4], 30 | 'RAMP': [0x02, 0xD1], 31 | 'PRIME_DELAY': [0x02, 0xD2], 32 | 'GPM': [0x02, 0xE4], 33 | 34 | '__0x03, 0x00__': [0x03, 0x00], # 7860 35 | '__0x03, 0x16__': [0x03, 0x16], # 1600 36 | 'PRIME_SENSITIVITY': [0x03, 0x17], 37 | '__0x03, 0x18__': [0x03, 0x18], # 50 # Vacuum Flow? 38 | '__0x03, 0x19__': [0x03, 0x19], # 55 # Max Priming Flow? 39 | 'SVRS_RESTART_TIMER': [0x03, 0x1A], 40 | 'SVRS_RESTART_ENABLE': [0x03, 0x1B], 41 | 'RUNNING_PROGRAM': [0x03, 0x21], 42 | '__0x03, 0x22__': [0x03, 0x22], # 0 43 | '__0x03, 0x23__': [0x03, 0x23], # 0 44 | '__0x03, 0x24__': [0x03, 0x24], # 0 45 | '__0x03, 0x25__': [0x03, 0x25], # 0 46 | '__0x03, 0x26__': [0x03, 0x26], # 0 47 | '__0x03, 0x27__': [0x03, 0x27], # Through [0x03, 0x2A] -- offset by Program id. Related to Program RPM? 48 | 'SET_TIMER': [0x03, 0x2B], 49 | '__0x03, 0x2C__': [0x03, 0x2C], # 2 50 | '__0x03, 0x2D__': [0x03, 0x2D], # 1 51 | '__0x03, 0x2E__': [0x03, 0x2E], # 0 52 | 'CELSIUS': [0x03, 0x30], 53 | '24_HOUR': [0x03, 0x31], 54 | '__0x03, 0x34__': [0x03, 0x34], # 3445 55 | '__0x03, 0x35__': [0x03, 0x35], # 1115 56 | '__0x03, 0x36__': [0x03, 0x36], # 10 Error 25 if I try to set it to anything 57 | '__0x03, 0x37__': [0x03, 0x37], # 1 58 | '__0x03, 0x38__': [0x03, 0x38], # 0 59 | '__0x03, 0x39__': [0x03, 0x39], # 3445 60 | '__0x03, 0x3A__': [0x03, 0x3A], # 1115 61 | '__0x03, 0x3B__': [0x03, 0x3B], # 10 Error 25 if I try to set it to anything 62 | '__0x03, 0x3C__': [0x03, 0x3C], # 2 63 | '__0x03, 0x3D__': [0x03, 0x3D], # 0 64 | '__0x03, 0x3E__': [0x03, 0x3E], # 0 65 | 66 | 'PROGRAM_MODE': [0x03, 0x85], # Through [0x03, 0x8C] -- offset by Program id 67 | 'PROGRAM_RPM': [0x03, 0x8D], # Through [0x03, 0x94] -- offset by Program id 68 | 'SCHEDULE_START': [0x03, 0x95], # Through [0x03, 0x9C] -- offset by Program id 69 | 'SCHEDULE_END': [0x03, 0x9D], # Through [0x03, 0xA4] -- offset by Program id 70 | 'EGG_TIMER': [0x03, 0xA5], # Through [0x03, 0xAC] -- offset by Program id 71 | 72 | 'TIME_OUT_TIMER': [0x03, 0xAD], 73 | 'QUICK_RPM': [0x03, 0xAE], 74 | 'QUICK_TIMER': [0x03, 0xAF], 75 | 'ANTIFREEZE_ENABLE': [0x03, 0xB0], 76 | 'ANTIFREEZE_RPM': [0x03, 0xB1], 77 | 'ANTIFREEZE_TEMP': [0x03, 0xB2], 78 | 'PRIME_ENABLE': [0x03, 0xB3], 79 | '__0x03, 0xB4__': [0x03, 0xB4], # 3450 Prime RPM? 80 | 'PRIME_MAX_TIME': [0x03, 0xB5], 81 | 'MIN_RPM': [0x03, 0xB6], 82 | 'MAX_RPM': [0x03, 0xB7], 83 | 'PASSWORD_ENABLE': [0x03, 0xB8], 84 | 'PASSWORD_TIMEOUT': [0x03, 0xB9], 85 | 'PASSWORD': [0x03, 0xBA], 86 | 'PROGRAM_RPM_ALT': [0x03, 0xBB], # Through [0x03, 0xBE] -- offset by Program id 87 | '__0x03, 0xC0__': [0x03, 0xC0], # 1 88 | '__0x03, 0xC1__': [0x03, 0xC1], # 1 89 | '__0x03, 0xC2__': [0x03, 0xC2], # 1441 90 | '__0x03, 0xC3__': [0x03, 0xC3], # 0 91 | 'SOFT_PRIME_COUNTER': [0x03, 0xC4], # Error 11 when trying to set. 92 | } 93 | 94 | WEEKDAYS = { 95 | 'SUNDAY': 1, 96 | 'MONDAY': 2, 97 | 'TUESDAY': 4, 98 | 'WEDNESDAY': 8, 99 | 'THURSDAY': 16, 100 | 'FRIDAY': 32, 101 | 'SATURDAY': 64, 102 | } 103 | 104 | 105 | def bytelist(x): 106 | return list(x.to_bytes(2, byteorder='big')) 107 | 108 | 109 | class Pump(): 110 | def __init__(self, id): 111 | # self._address = ADDRESSES["INTELLIFLO_PUMP_" + str(index)] 112 | self._address = 0x60 + id - 1 113 | 114 | def send(self, action, data=None): 115 | return Packet(dst=self.address, action=action, data=data).send() 116 | 117 | def ping(self): 118 | response = self.send(0x00) 119 | if response.payload == [Packet.HEADER, 120 | Packet.VERSION, 121 | 0x21, 122 | self.address, 123 | 0, 124 | 0]: 125 | return True 126 | return False 127 | 128 | def set(self, address, value): 129 | return self.send(0x01, address + bytelist(value)).to_int 130 | 131 | def get(self, address): 132 | return self.send(0x02, address).to_int 133 | 134 | @property 135 | def time(self): 136 | response = self.send(0x03) 137 | return datetime.time(response.data[0], response.data[1]) 138 | 139 | @time.setter 140 | def time(self, time): 141 | self.send(0x03, [time.hour, time.minute, time.second]) 142 | 143 | def sync_time(self): 144 | self.time = datetime.datetime.now() 145 | 146 | @property 147 | def remote_control(self): 148 | response = self.send(0x04) 149 | return response.data[0] == 1 150 | 151 | @remote_control.setter 152 | def remote_control(self, state): 153 | state = 0xFF if state else 0x00 154 | self.send(0x04, state) 155 | # This function runs successfully on the pump, but doesn't actually 156 | # change the state. Right after setting to 0, requesting the value 157 | # still returns 1. Being that we're a remote controller by definition 158 | # the pump may be being smarter than us here. 159 | 160 | @property 161 | def selected_program(self): 162 | response = self.send(0x05) 163 | return response.data 164 | # This function runs successfully on the pump even if you provide an 165 | # invalid program id -- e.g. an integer greater than 8. Generally 166 | # speaking, the pump throws ERROR 10 for invalid parameter. I'm not 167 | # convinced 0x05 is actually the selected program. Sometimes running 168 | # this command starts the pump, but it's always on Program 1. 169 | 170 | @selected_program.setter 171 | def selected_program(self, program): 172 | self.send(0x05, [program]) 173 | # As above, this runs successfully on the pump and the return payload 174 | # looks like the setting was updated, but requesting the value after 175 | # updating it, it always returns 1. Going to have to play more with 176 | # this. 177 | 178 | @property 179 | def run(self): 180 | return self.status['run'] 181 | # response = self.send(0x06) 182 | # return response.data 183 | # Calling 0x06 without any parameters always returns 1, but status[0] 184 | # updates as it should with the appropriate run state. 185 | 186 | @run.setter 187 | def run(self, state): 188 | state = 0x0A if state else 0x04 189 | print("Attempting to set run:", state) 190 | for x in range(0, 120): 191 | self.send(0x06, state) 192 | print("Desired run state:", state, "Actual run state:", self.run) 193 | if self.run == state: 194 | print("Successfully set run:", state) 195 | return 196 | sleep(1) 197 | raise ValueError("Did not achieve desired run state within 2-minutes.") 198 | 199 | @property 200 | def status(self): 201 | response = self.send(0x07) 202 | data = response.data 203 | return { 204 | 'run': data[0], 205 | 'mode': data[1], 206 | 'drive': data[2], 207 | 'watts': 256*data[3]+data[4], 208 | 'rpm': 256*data[5]+data[6], 209 | 'timer': [data[11], data[12]], 210 | 'time': [data[13], data[14]] 211 | } 212 | 213 | # @property 214 | # def running_program(self): 215 | # return(int(self.send(ACTIONS['GET'], SETTING['RUNNING_PROGRAM']).to_int/8)) 216 | # 217 | # @running_program.setter 218 | # def running_program(self, index): 219 | # self.send(ACTIONS['SET'], SETTING['RUNNING_PROGRAM'] + [index*8]) 220 | # 221 | 222 | @property 223 | def address(self): 224 | return self._address 225 | 226 | @address.setter 227 | def address(self, address): 228 | self._address = Packet( 229 | dst=self.address, 230 | action=ACTIONS['SET'], 231 | data=SETTING['ADDRESS'] + bytelist(int(address)) 232 | ).send().to_int 233 | 234 | @property 235 | def ampm(self): 236 | return not self.send(ACTIONS['GET'], SETTING['24_HOUR']).to_int 237 | 238 | @ampm.setter 239 | def ampm(self, state): 240 | self.send(ACTIONS['SET'], SETTING['24_HOUR'] + bytelist(not state)) 241 | 242 | @property 243 | def antifreeze_enable(self): 244 | return self.send(ACTIONS['GET'], SETTING['ANTIFREEZE_ENABLE']).to_int 245 | 246 | @antifreeze_enable.setter 247 | def antifreeze_enable(self, state): 248 | self.send(ACTIONS['SET'], SETTING['ANTIFREEZE_ENABLE'] + bytelist(state)) 249 | 250 | @property 251 | def antifreeze_rpm(self): 252 | return self.send(ACTIONS['GET'], SETTING['ANTIFREEZE_RPM']).to_int 253 | 254 | @antifreeze_rpm.setter 255 | def antifreeze_rpm(self, rpm): 256 | self.send(ACTIONS['SET'], SETTING['ANTIFREEZE_RPM'] + bytelist(rpm)) 257 | 258 | @property 259 | def antifreeze_temp(self): 260 | return self.send(ACTIONS['GET'], SETTING['ANTIFREEZE_TEMP']).to_int 261 | 262 | @antifreeze_temp.setter 263 | def antifreeze_temp(self, temp): 264 | self.send(ACTIONS['SET'], SETTING['ANTIFREEZE_TEMP'] + bytelist(temp)) 265 | 266 | @property 267 | def celsius(self): 268 | return self.send(ACTIONS['GET'], SETTING['CELSIUS']).to_int 269 | 270 | @celsius.setter 271 | def celsius(self, state): 272 | self.send(ACTIONS['SET'], SETTING['CELSIUS'] + bytelist(state)) 273 | 274 | @property 275 | def contrast(self): 276 | return self.send(ACTIONS['GET'], SETTING['CONTRAST']).to_int 277 | 278 | @contrast.setter 279 | def contrast(self, state): 280 | self.send(ACTIONS['SET'], SETTING['CONTRAST'] + bytelist(state)) 281 | 282 | @property 283 | def dt(self): 284 | return self.send(ACTIONS['GET_DATETIME']).payload 285 | # return self.send(0x03) 286 | # Need to actually implement this one 287 | # Error 19: ? 288 | # Error 8: Missing parameters? 289 | # Error 7: Extra parameters? 290 | 291 | @dt.setter 292 | def dt(self, data): 293 | return self.send(ACTIONS['SET_DATETIME'], 294 | [ 295 | data['hour'], 296 | data['minute'], 297 | WEEKDAYS[data['dow']], 298 | data['dom'], 299 | data['month'], 300 | data['year'], 301 | data['dst'], 302 | data['auto_dst'] 303 | ]) 304 | 305 | @property 306 | def fahrenheit(self): 307 | return not self.celsius 308 | 309 | @fahrenheit.setter 310 | def fahrenheit(self, state): 311 | self.celsius = not state 312 | 313 | @property 314 | def id(self): 315 | return self.address - 95 316 | 317 | @id.setter 318 | def id(self, id): 319 | self.address = id + 95 320 | 321 | @property 322 | def max_rpm(self): 323 | return self.send(ACTIONS['GET'], SETTING['MAX_RPM']).to_int 324 | 325 | @max_rpm.setter 326 | def max_rpm(self, rpm): 327 | self.send(ACTIONS['SET'], SETTING['MAX_RPM'] + bytelist(rpm)) 328 | 329 | @property 330 | def min_rpm(self): 331 | return self.send(ACTIONS['GET'], SETTING['MIN_RPM']).to_int 332 | 333 | @min_rpm.setter 334 | def min_rpm(self, rpm): 335 | self.send(ACTIONS['SET'], SETTING['MIN_RPM'] + bytelist(rpm)) 336 | 337 | @property 338 | def mode(self): 339 | return self.status['mode'] 340 | 341 | @property 342 | def password_enable(self): 343 | return self.send(ACTIONS['GET'], SETTING['PASSWORD_ENABLE']).to_int 344 | 345 | @password_enable.setter 346 | def password_enable(self, state): 347 | self.send(ACTIONS['SET'], SETTING['PASSWORD_ENABLE'] + bytelist(state)) 348 | 349 | @property 350 | def password_timeout(self): 351 | return self.send(ACTIONS['GET'], SETTING['PASSWORD_TIMEOUT']).to_int 352 | 353 | @password_timeout.setter 354 | def password_timeout(self, timeout): 355 | self.send(ACTIONS['SET'], SETTING['PASSWORD_TIMEOUT'] + bytelist(timeout)) 356 | 357 | @property 358 | def password(self): 359 | return self.send(ACTIONS['GET'], SETTING['PASSWORD']).to_int 360 | 361 | @password.setter 362 | def password(self, password): 363 | self.send(ACTIONS['SET'], SETTING['PASSWORD'] + bytelist(password)) 364 | 365 | @property 366 | def prime_enable(self): 367 | return self.send(ACTIONS['GET'], SETTING['PRIME_ENABLE']).to_int 368 | 369 | @prime_enable.setter 370 | def prime_enable(self, state): 371 | self.send(ACTIONS['SET'], SETTING['PRIME_ENABLE'] + bytelist(state)) 372 | 373 | @property 374 | def prime_delay(self): 375 | return self.send(ACTIONS['GET'], SETTING['PRIME_DELAY']).to_int 376 | 377 | @prime_delay.setter 378 | def prime_delay(self, minutes): 379 | self.send(ACTIONS['SET'], SETTING['PRIME_DELAY'] + bytelist(minutes)) 380 | 381 | @property 382 | def prime_max_time(self): 383 | return self.send(ACTIONS['GET'], SETTING['PRIME_MAX_TIME']).to_int 384 | 385 | @prime_max_time.setter 386 | def prime_max_time(self, minutes): 387 | self.send(ACTIONS['SET'], SETTING['PRIME_MAX_TIME'] + bytelist(minutes)) 388 | 389 | @property 390 | def prime_sensitivity(self): 391 | return self.send(ACTIONS['GET'], SETTING['PRIME_SENSITIVITY']).to_int 392 | 393 | @prime_sensitivity.setter 394 | def prime_sensitivity(self, sensitivity): 395 | self.send(ACTIONS['SET'], SETTING['PRIME_SENSITIVITY'] + bytelist(sensitivity)) 396 | 397 | @property 398 | def quick_rpm(self): 399 | return self.send(ACTIONS['GET'], SETTING['QUICK_RPM']).to_int 400 | 401 | @quick_rpm.setter 402 | def quick_rpm(self, rpm): 403 | self.send(ACTIONS['SET'], SETTING['QUICK_RPM'] + bytelist(rpm)) 404 | 405 | @property 406 | def quick_timer(self): 407 | minutes = self.send(ACTIONS['GET'], SETTING['QUICK_TIMER']).to_int 408 | return [int(minutes/60), minutes % 60] 409 | 410 | @quick_timer.setter 411 | def quick_timer(self, time): 412 | minutes = 60 * time[0] + time[1] 413 | self.send(ACTIONS['SET'], SETTING['QUICK_TIMER'] + bytelist(minutes)) 414 | 415 | def program(self, index): 416 | return Program(self, index) 417 | 418 | @property 419 | def programs(self): 420 | return [self.program(index) for index in range(1, 9)] 421 | 422 | @property 423 | def ramp(self): 424 | return self.send(ACTIONS['GET'], SETTING['RAMP']).to_int 425 | 426 | @ramp.setter 427 | def ramp(self, rpm): 428 | self.send(ACTIONS['SET'], SETTING['RAMP'] + bytelist(rpm)) 429 | 430 | @property 431 | def rpm(self): 432 | return self.get(SETTING['ACTUAL_RPM']) 433 | 434 | @rpm.setter 435 | def rpm(self, rpm): 436 | print(f'Setting RPM: {rpm}') 437 | count = 0 438 | self.set(SETTING['TARGET_RPM'], rpm) 439 | while self.rpm != self.trpm: 440 | print(f'Target RPM: {self.trpm}, Actual RPM: {self.rpm}') 441 | sleep(1) 442 | count += 1 443 | if count > 120: 444 | self.set(SETTING['TARGET_RPM'], rpm) 445 | count = 0 446 | print(f'{Style.OKGREEN}Target RPM: {self.trpm}, Actual RPM: {self.rpm}{Style.ENDC}') 447 | 448 | @property 449 | def trpm(self): 450 | return self.get(SETTING['TARGET_RPM']) 451 | 452 | @trpm.setter 453 | def trpm(self, rpm): 454 | self.set(SETTING['TARGET_RPM'], rpm) 455 | 456 | def maintain_speed(self): 457 | self.trpm = self.rpm 458 | 459 | @property 460 | def soft_prime_counter(self): 461 | return self.send(ACTIONS['GET'], SETTING['SOFT_PRIME_COUNTER']).to_int 462 | 463 | @soft_prime_counter.setter 464 | def soft_prime_counter(self, minutes): 465 | self.send(ACTIONS['SET'], SETTING['SOFT_PRIME_COUNTER'] + bytelist(minutes)) 466 | 467 | @property 468 | def svrs_alarm(self): 469 | return self.send(ACTIONS['GET'], SETTING['SVRS_ALARM']).to_int 470 | 471 | @property 472 | def svrs_restart_enable(self): 473 | return self.send(ACTIONS['GET'], SETTING['SVRS_RESTART_ENABLE']).to_int 474 | 475 | @svrs_restart_enable.setter 476 | def svrs_restart_enable(self, state): 477 | self.send(ACTIONS['SET'], SETTING['SVRS_RESTART_ENABLE'] + bytelist(state)) 478 | 479 | @property 480 | def svrs_restart_timer(self): 481 | return self.send(ACTIONS['GET'], SETTING['SVRS_RESTART_TIMER']).to_int 482 | 483 | @svrs_restart_timer.setter 484 | def svrs_restart_timer(self, seconds): 485 | self.send(ACTIONS['SET'], SETTING['SVRS_RESTART_TIMER'] + bytelist(seconds)) 486 | 487 | @property 488 | def timer(self): 489 | return self.status['timer'] 490 | 491 | @property 492 | def time_out_timer(self): 493 | minutes = self.send(ACTIONS['GET'], SETTING['TIME_OUT_TIMER']).to_int 494 | return [int(minutes/60), minutes % 60] 495 | 496 | @time_out_timer.setter 497 | def time_out_timer(self, time): 498 | minutes = 60 * time[0] + time[1] 499 | self.send(ACTIONS['SET'], SETTING['TIME_OUT_TIMER'] + bytelist(minutes)) 500 | 501 | @property 502 | def watts(self): 503 | return self.status['watts'] 504 | 505 | 506 | class Program(): 507 | 508 | PROGRAM_1 = 0x02 509 | PROGRAM_2 = 0x03 510 | PROGRAM_3 = 0x04 511 | PROGRAM_4 = 0x05 512 | PROGRAM_5 = 0x06 513 | PROGRAM_6 = 0x07 514 | PROGRAM_7 = 0x08 515 | PROGRAM_8 = 0x09 516 | QUICK_CLEAN = 0x0a 517 | TIME_OUT = 0x0b 518 | 519 | SCHEDULE_START = [0x03, 0x95] 520 | SCHEDULE_END = [0x03, 0x9D] 521 | EGG_TIMER = [0x03, 0xA5] 522 | MODE = [0x03, 0x85] 523 | RPM = [0x03, 0x8D] 524 | 525 | MANUAL_MODE = 0 526 | EGG_TIMER_MODE = 1 527 | SCHEDULE_MODE = 2 528 | DISABLED = 3 529 | 530 | def __init__(self, pump, id): 531 | self.pump = pump 532 | self.id = id 533 | 534 | def my(self, address): 535 | return [address[0], address[1] + self.id - 1] 536 | 537 | @property 538 | def rpm(self): 539 | return self.pump.get(self.my(Program.RPM)) 540 | 541 | @rpm.setter 542 | def rpm(self, rpm): 543 | return self.pump.set(self.my(Program.RPM), rpm) 544 | 545 | @property 546 | def mode(self): 547 | return self.pump.get(self.my(Program.MODE)) 548 | 549 | @mode.setter 550 | def mode(self, mode): 551 | self.pump.set(self.my(Program.MODE), mode) 552 | 553 | @property 554 | def egg_timer(self): 555 | minutes = self.pump.get(self.my(Program.EGG_TIMER)) 556 | return [int(minutes/60), minutes % 60] 557 | 558 | @egg_timer.setter 559 | def egg_timer(self, duration): 560 | minutes = 60 * duration[0] + duration[1] 561 | self.pump.set(self.my(Program.EGG_TIMER), minutes) 562 | 563 | @property 564 | def schedule_start(self): 565 | minutes = self.pump.get(self.my(Program.SCHEDULE_START)) 566 | return [int(minutes/60), minutes % 60] 567 | 568 | @schedule_start.setter 569 | def schedule_start(self, time): 570 | minutes = 60 * time[0] + time[1] 571 | self.pump.set(self.my(Program.SCHEDULE_START), minutes) 572 | 573 | @property 574 | def schedule_end(self): 575 | minutes = self.pump.get(self.my(Program.SCHEDULE_END)) 576 | return [int(minutes/60), minutes % 60] 577 | 578 | @schedule_end.setter 579 | def schedule_end(self, time): 580 | minutes = 60 * time[0] + time[1] 581 | self.pump.set(self.my(Program.SCHEDULE_END), minutes) 582 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | attr=!messy 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name='pypentair', 5 | version='0.0.1', 6 | packages=find_packages(), 7 | include_package_data=True, 8 | zip_safe=False, 9 | install_requires=[ 10 | 'pyserial' 11 | ], 12 | ) 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cilynx/pypentair/3ea42b768fe97777ec4655668fb847c7118ef46f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_pentair.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import random 3 | from nose.plugins.attrib import attr 4 | from pypentair import Packet, Pump 5 | 6 | PAYLOAD_HEADER = 0xA5 7 | SRC = 0x21 8 | DST = 0x60 9 | GET_PUMP_STATUS = 0x07 10 | REMOTE_CONTROL = 0x04 11 | ON = 0xFF 12 | PUMP_PROGRAM = 0x01 13 | SET = 0x02 14 | RPM = 0xC4 15 | VERSION = 0x00 16 | 17 | class TestPumpMethods(unittest.TestCase): 18 | 19 | @attr('messy') 20 | def test_power_on(self): 21 | Pump(1).power = True 22 | self.assertEqual(Pump(1).power, True) 23 | 24 | @attr('messy') 25 | def test_power_off(self): 26 | Pump(1).power = False 27 | self.assertEqual(Pump(1).power, False) 28 | 29 | @attr('messy') 30 | def test_set_rpm(self): 31 | for rpm in [3000, 2500, 2000, 1100]: 32 | with self.subTest(rpm=rpm): 33 | Pump(1).rpm = rpm 34 | self.assertEqual(Pump(1).rpm, rpm) 35 | # Ugly formula below is best-fit polynomial to manually collected data. 36 | # Across the usable rpm range, deviation stays <100 watts 37 | self.assertAlmostEqual(Pump(1).watts, 0.0004*(rpm**2)-0.8*rpm+611, delta=100) 38 | 39 | @attr('messy') 40 | def test_programs(self): 41 | for x in range(1,4): 42 | Pump(1).running_program = x 43 | self.assertEqual(Pump(1).running_program, x) 44 | 45 | def test_ramp(self): 46 | Pump(1).ramp = 100 47 | self.assertEqual(Pump(1).ramp, 100) 48 | Pump(1).ramp = 200 49 | self.assertEqual(Pump(1).ramp, 200) 50 | 51 | def test_celsius(self): 52 | Pump(1).celsius = 1 53 | self.assertEqual(Pump(1).fahrenheit, 0) 54 | self.assertEqual(Pump(1).celsius, 1) 55 | Pump(1).celsius = 0 56 | self.assertEqual(Pump(1).fahrenheit, 1) 57 | self.assertEqual(Pump(1).celsius, 0) 58 | 59 | def test_fahrenheit(self): 60 | Pump(1).fahrenheit = 1 61 | self.assertEqual(Pump(1).fahrenheit, 1) 62 | self.assertEqual(Pump(1).celsius, 0) 63 | Pump(1).fahrenheit = 0 64 | self.assertEqual(Pump(1).fahrenheit, 0) 65 | self.assertEqual(Pump(1).celsius, 1) 66 | 67 | def test_contrast(self): 68 | Pump(1).contrast = 1 69 | self.assertEqual(Pump(1).contrast, 1) 70 | Pump(1).contrast = 3 71 | self.assertEqual(Pump(1).contrast, 3) 72 | 73 | def test_address(self): 74 | Pump(1).address = 97 75 | self.assertEqual(Pump(2).address, 97) 76 | Pump(2).address = 96 77 | self.assertEqual(Pump(1).address, 96) 78 | 79 | def test_id(self): 80 | Pump(1).id = 2 81 | self.assertEqual(Pump(2).address, 97) 82 | self.assertEqual(Pump(2).id, 2) 83 | Pump(2).id = 1 84 | self.assertEqual(Pump(1).address, 96) 85 | self.assertEqual(Pump(1).id, 1) 86 | 87 | def test_ampm(self): 88 | Pump(1).ampm = False 89 | self.assertEqual(Pump(1).ampm, False) 90 | Pump(1).ampm = True 91 | self.assertEqual(Pump(1).ampm, True) 92 | 93 | def test_max_speed(self): 94 | Pump(1).max_speed = 3445 95 | self.assertEqual(Pump(1).max_speed, 3445) 96 | Pump(1).max_speed = 3450 97 | self.assertEqual(Pump(1).max_speed, 3450) 98 | 99 | def test_min_speed(self): 100 | Pump(1).min_speed = 1105 101 | self.assertEqual(Pump(1).min_speed, 1105) 102 | Pump(1).min_speed = 1100 103 | self.assertEqual(Pump(1).min_speed, 1100) 104 | 105 | def test_enable_password(self): 106 | Pump(1).password_enable = True 107 | self.assertEqual(Pump(1).password_enable, True) 108 | Pump(1).password_enable = False 109 | self.assertEqual(Pump(1).password_enable, False) 110 | 111 | def test_set_password_timeout(self): 112 | Pump(1).password_timeout = 360 113 | self.assertEqual(Pump(1).password_timeout, 360) 114 | Pump(1).password_timeout = 10 115 | self.assertEqual(Pump(1).password_timeout, 10) 116 | 117 | def test_set_password(self): 118 | Pump(1).password = 1235 119 | self.assertEqual(Pump(1).password, 1235) 120 | Pump(1).password = 1234 121 | self.assertEqual(Pump(1).password, 1234) 122 | 123 | def test_quick_clean(self): 124 | rpm = random.randint(2000,3000) 125 | Pump(1).quick_rpm = rpm 126 | self.assertEqual(Pump(1).quick_rpm, rpm) 127 | Pump(1).quick_rpm = 2000 128 | self.assertEqual(Pump(1).quick_rpm, 2000) 129 | 130 | def test_quick_timer(self): 131 | hours = random.randint(0,9) 132 | minutes = random.randint(0,59) 133 | Pump(1).quick_timer = [hours, minutes] 134 | self.assertEqual(Pump(1).quick_timer, [hours, minutes]) 135 | Pump(1).quick_timer = [0, 10] 136 | self.assertEqual(Pump(1).quick_timer, [0, 10]) 137 | 138 | def test_prime_enable(self): 139 | Pump(1).prime_enable = False 140 | self.assertEqual(Pump(1).prime_enable, False) 141 | Pump(1).prime_enable = True 142 | self.assertEqual(Pump(1).prime_enable, True) 143 | 144 | def test_prime_max_time(self): 145 | time = random.randint(1, 30) 146 | Pump(1).prime_max_time = time 147 | self.assertEqual(Pump(1).prime_max_time, time) 148 | Pump(1).prime_max_time = 11 149 | self.assertEqual(Pump(1).prime_max_time, 11) 150 | 151 | def test_prime_sensitivity(self): 152 | sens = random.randint(1, 100) 153 | Pump(1).prime_sensitivity = sens 154 | self.assertEqual(Pump(1).prime_sensitivity, sens) 155 | Pump(1).prime_sensitivity = 3 156 | self.assertEqual(Pump(1).prime_sensitivity, 3) 157 | 158 | def test_prime_delay(self): 159 | delay = random.randint(1, 600) 160 | Pump(1).prime_delay = delay 161 | self.assertEqual(Pump(1).prime_delay, delay) 162 | Pump(1).prime_delay = 20 163 | self.assertEqual(Pump(1).prime_delay, 20) 164 | 165 | def test_antifreeze_enable(self): 166 | Pump(1).antifreeze_enable = False 167 | self.assertEqual(Pump(1).antifreeze_enable, False) 168 | Pump(1).antifreeze_enable = True 169 | self.assertEqual(Pump(1).antifreeze_enable, True) 170 | 171 | def test_antifreeze_rpm(self): 172 | rpm = random.randint(2000, 3000) 173 | Pump(1).antifreeze_rpm = rpm 174 | self.assertEqual(Pump(1).antifreeze_rpm, rpm) 175 | Pump(1).antifreeze_rpm = 1100 176 | self.assertEqual(Pump(1).antifreeze_rpm, 1100) 177 | 178 | def test_antifreeze_temp(self): 179 | temp = random.randint(40, 50) 180 | Pump(1).antifreeze_temp = temp 181 | self.assertEqual(Pump(1).antifreeze_temp, temp) 182 | Pump(1).antifreeze_temp = 40 183 | self.assertEqual(Pump(1).antifreeze_temp, 40) 184 | 185 | def test_svrs_restart_enable(self): 186 | Pump(1).svrs_restart_enable = False 187 | self.assertEqual(Pump(1).svrs_restart_enable, False) 188 | Pump(1).svrs_restart_enable = True 189 | self.assertEqual(Pump(1).svrs_restart_enable, True) 190 | 191 | def test_svrs_restart_timer(self): 192 | seconds = random.randint(30, 300) 193 | Pump(1).svrs_restart_timer = seconds 194 | self.assertEqual(Pump(1).svrs_restart_timer, seconds) 195 | Pump(1).svrs_restart_timer = 120 196 | self.assertEqual(Pump(1).svrs_restart_timer, 120) 197 | 198 | def test_time_out_timer(self): 199 | hours = random.randint(0,9) 200 | minutes = random.randint(0,59) 201 | Pump(1).time_out_timer = [hours, minutes] 202 | self.assertEqual(Pump(1).time_out_timer, [hours, minutes]) 203 | Pump(1).time_out_timer = [3, 0] 204 | self.assertEqual(Pump(1).time_out_timer, [3, 0]) 205 | 206 | def test_soft_prime_counter(self): 207 | self.assertEqual(Pump(1).soft_prime_counter, 10) 208 | # To really test this, we need to stall the pump. 209 | # TODO: Revisit this once valve automation is done. 210 | 211 | class TestProgramMethods(unittest.TestCase): 212 | 213 | def test_rpm(self): 214 | for program in Pump(1).programs: 215 | rpm = random.randint(2000,3000) 216 | program.rpm = rpm 217 | self.assertEqual(Pump(1).program(program.index).rpm, rpm) 218 | program.rpm = 1100 219 | self.assertEqual(Pump(1).program(program.index).rpm, 1100) 220 | 221 | class TestSpeedMethods(unittest.TestCase): 222 | 223 | def test_mode(self): 224 | Pump(1).speed(1).mode = "EGG_TIMER" 225 | self.assertEqual(Pump(1).speed(1).mode, "EGG_TIMER") 226 | Pump(1).speed(1).mode = 0 227 | self.assertEqual(Pump(1).speed(1).mode, "MANUAL") 228 | Pump(1).speed(5).mode = "SCHEDULE" 229 | self.assertEqual(Pump(1).speed(5).mode, "SCHEDULE") 230 | Pump(1).speed(5).mode = 3 231 | self.assertEqual(Pump(1).speed(5).mode, "DISABLED") 232 | 233 | def test_rpm(self): 234 | Pump(1).speed(1).rpm = 2000 235 | self.assertEqual(Pump(1).speed(1).rpm, 2000) 236 | Pump(1).speed(1).rpm = 1100 237 | self.assertEqual(Pump(1).speed(1).rpm, 1100) 238 | 239 | def test_schedule_start(self): 240 | Pump(1).speed(1).schedule_start = [7, 1] 241 | self.assertEqual(Pump(1).speed(1).schedule_start, [7, 1]) 242 | Pump(1).speed(1).schedule_start = [11, 0] 243 | self.assertEqual(Pump(1).speed(1).schedule_start, [11, 0]) 244 | 245 | def test_schedule_end(self): 246 | Pump(1).speed(1).schedule_end = [7, 1] 247 | self.assertEqual(Pump(1).speed(1).schedule_end, [7, 1]) 248 | Pump(1).speed(1).schedule_end = [18, 0] 249 | self.assertEqual(Pump(1).speed(1).schedule_end, [18, 0]) 250 | 251 | def test_egg_timer(self): 252 | for speed in Pump(1).speeds: 253 | speed.egg_timer = [7, 1] 254 | self.assertEqual(Pump(1).speed(speed.index).egg_timer, [7, 1]) 255 | speed.egg_timer = [0, 5] 256 | self.assertEqual(Pump(1).speed(speed.index).egg_timer, [0, 5]) 257 | 258 | class TestPacketMethods(unittest.TestCase): 259 | 260 | ### Data Length, because incoming data can have several formats 261 | 262 | def test_data_length_with_no_data(self): 263 | packet = Packet(dst=DST, action=GET_PUMP_STATUS) 264 | self.assertEqual(packet.data_length, 0) 265 | 266 | def test_data_length_with_single_data_byte(self): 267 | packet = Packet(dst=DST, action=REMOTE_CONTROL, data=ON) 268 | self.assertEqual(packet.data_length, 1) 269 | 270 | def test_data_length_with_single_data_byte_list(self): 271 | packet = Packet(dst=DST, action=REMOTE_CONTROL, data=[ON]) 272 | self.assertEqual(packet.data_length, 1) 273 | 274 | def test_data_length_with_multiple_data_bytes(self): 275 | packet = Packet(dst=DST, action=PUMP_PROGRAM, data=[SET, RPM, 5, 220]) 276 | self.assertEqual(packet.data_length, 4) 277 | 278 | ### Payload, as above, mostly checking various input format handling 279 | 280 | def test_payload_with_no_data(self): 281 | packet = Packet(dst=DST, action=GET_PUMP_STATUS) 282 | self.assertEqual(packet.payload, [PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0]) 283 | 284 | def test_payload_with_single_data_byte(self): 285 | packet = Packet(dst=DST, action=REMOTE_CONTROL, data=ON) 286 | self.assertEqual(packet.payload, [PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON]) 287 | 288 | def test_payload_with_single_data_byte_list(self): 289 | packet = Packet(dst=DST, action=REMOTE_CONTROL, data=[ON]) 290 | self.assertEqual(packet.payload, [PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON]) 291 | 292 | def test_payload_with_multiple_data_bytes(self): 293 | packet = Packet(dst=DST, action=PUMP_PROGRAM, data=[SET, RPM, 5, 220]) 294 | self.assertEqual(packet.payload, [PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220]) 295 | 296 | ### Checksums and Such 297 | 298 | def test_checkbytes(self): 299 | packet = Packet(dst=DST, action=PUMP_PROGRAM, data=[SET, RPM, 5, 220]) 300 | self.assertEqual(packet.checkbytes, [2, 210]) 301 | 302 | def test_checksum(self): 303 | packet = Packet(dst=DST, action=PUMP_PROGRAM, data=[SET, RPM, 5, 220]) 304 | self.assertEqual(packet.checksum, 2*256 + 210) 305 | 306 | ### Key=Value Construction 307 | 308 | def test_kv_construction_no_data(self): 309 | packet = Packet(dst=DST, action=GET_PUMP_STATUS) 310 | self.assertEqual(packet.dst, DST) 311 | self.assertEqual(packet.src, SRC) 312 | self.assertEqual(packet.action, GET_PUMP_STATUS) 313 | self.assertEqual(packet.data_length, 0) 314 | self.assertEqual(packet.data, None) 315 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0, 1, 45]) 316 | 317 | def test_kv_construction_single_data_byte(self): 318 | packet = Packet(dst=DST, action=REMOTE_CONTROL, data=ON) 319 | self.assertEqual(packet.dst, DST) 320 | self.assertEqual(packet.src, SRC) 321 | self.assertEqual(packet.action, REMOTE_CONTROL) 322 | self.assertEqual(packet.data_length, 1) 323 | self.assertEqual(packet.data, [ON]) 324 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 325 | 326 | def test_kv_construction_single_data_byte_list(self): 327 | packet = Packet(dst=DST, action=REMOTE_CONTROL, data=[ON]) 328 | self.assertEqual(packet.dst, DST) 329 | self.assertEqual(packet.src, SRC) 330 | self.assertEqual(packet.action, REMOTE_CONTROL) 331 | self.assertEqual(packet.data_length, 1) 332 | self.assertEqual(packet.data, [ON]) 333 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 334 | 335 | def test_kv_construction_multiple_data_bytes(self): 336 | packet = Packet(dst=DST, action=PUMP_PROGRAM, data=[SET, RPM, 5, 220]) 337 | self.assertEqual(packet.dst, DST) 338 | self.assertEqual(packet.src, SRC) 339 | self.assertEqual(packet.action, PUMP_PROGRAM) 340 | self.assertEqual(packet.data_length, 4) 341 | self.assertEqual(packet.data, [SET, RPM, 5, 220]) 342 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 210]) 343 | 344 | ### Byte Construction, No Header, No Data 345 | 346 | def test_byte_construction_no_header_no_data_no_checksum(self): 347 | packet = Packet([DST, SRC, GET_PUMP_STATUS, 0]) 348 | self.assertEqual(packet.dst, DST) 349 | self.assertEqual(packet.src, SRC) 350 | self.assertEqual(packet.action, GET_PUMP_STATUS) 351 | self.assertEqual(packet.data_length, 0) 352 | self.assertEqual(packet.data, None) 353 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0, 1, 45]) 354 | 355 | def test_byte_construction_no_header_no_data_valid_checksum(self): 356 | packet = Packet([DST, SRC, GET_PUMP_STATUS, 0, 1, 45]) 357 | self.assertEqual(packet.dst, DST) 358 | self.assertEqual(packet.src, SRC) 359 | self.assertEqual(packet.action, GET_PUMP_STATUS) 360 | self.assertEqual(packet.data_length, 0) 361 | self.assertEqual(packet.data, None) 362 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0, 1, 45]) 363 | 364 | def test_byte_construction_no_header_no_data_invalid_checksum(self): 365 | with self.assertRaises(ValueError): 366 | Packet([DST, SRC, GET_PUMP_STATUS, 0, 1, 46]) 367 | 368 | ### Byte Construction, With Header, No Data 369 | 370 | def test_byte_construction_with_header_no_data_no_checksum(self): 371 | packet = Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0]) 372 | self.assertEqual(packet.dst, DST) 373 | self.assertEqual(packet.src, SRC) 374 | self.assertEqual(packet.action, GET_PUMP_STATUS) 375 | self.assertEqual(packet.data_length, 0) 376 | self.assertEqual(packet.data, None) 377 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0, 1, 45]) 378 | 379 | def test_byte_construction_with_header_no_data_valid_checksum(self): 380 | packet = Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0, 1, 45]) 381 | self.assertEqual(packet.dst, DST) 382 | self.assertEqual(packet.src, SRC) 383 | self.assertEqual(packet.action, GET_PUMP_STATUS) 384 | self.assertEqual(packet.data_length, 0) 385 | self.assertEqual(packet.data, None) 386 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0, 1, 45]) 387 | 388 | def test_byte_construction_with_header_no_data_invalid_checksum(self): 389 | with self.assertRaises(ValueError): 390 | Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, GET_PUMP_STATUS, 0, 1, 46]) 391 | 392 | ### Byte Construction, No Header, Single Data Byte 393 | 394 | def test_byte_construction_no_header_single_data_byte_no_checksum(self): 395 | packet = Packet([DST, SRC, REMOTE_CONTROL, 1, ON]) 396 | self.assertEqual(packet.dst, DST) 397 | self.assertEqual(packet.src, SRC) 398 | self.assertEqual(packet.action, REMOTE_CONTROL) 399 | self.assertEqual(packet.data_length, 1) 400 | self.assertEqual(packet.data, [ON]) 401 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 402 | 403 | def test_byte_construction_no_header_single_data_byte_valid_checksum(self): 404 | packet = Packet([DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 405 | self.assertEqual(packet.dst, DST) 406 | self.assertEqual(packet.src, SRC) 407 | self.assertEqual(packet.action, REMOTE_CONTROL) 408 | self.assertEqual(packet.data_length, 1) 409 | self.assertEqual(packet.data, [ON]) 410 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 411 | 412 | def test_byte_construction_no_header_single_data_byte_invalid_checksum(self): 413 | with self.assertRaises(ValueError): 414 | Packet([DST, SRC, REMOTE_CONTROL, 1, ON, 2, 43]) 415 | 416 | ### Byte Construction, With Header, Single Data Byte 417 | 418 | def test_byte_construction_with_header_single_data_byte_no_checksum(self): 419 | packet = Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON]) 420 | self.assertEqual(packet.dst, DST) 421 | self.assertEqual(packet.src, SRC) 422 | self.assertEqual(packet.action, REMOTE_CONTROL) 423 | self.assertEqual(packet.data_length, 1) 424 | self.assertEqual(packet.data, [ON]) 425 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 426 | 427 | def test_byte_construction_with_header_single_data_byte_valid_checksum(self): 428 | packet = Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 429 | self.assertEqual(packet.dst, DST) 430 | self.assertEqual(packet.src, SRC) 431 | self.assertEqual(packet.action, REMOTE_CONTROL) 432 | self.assertEqual(packet.data_length, 1) 433 | self.assertEqual(packet.data, [ON]) 434 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 42]) 435 | 436 | def test_byte_construction_with_header_single_data_byte_invalid_checksum(self): 437 | with self.assertRaises(ValueError): 438 | Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, REMOTE_CONTROL, 1, ON, 2, 43]) 439 | 440 | ### Byte Construction, No Header, Multiple Data Bytes 441 | 442 | def test_byte_construction_no_header_multiple_data_bytes_no_checksum(self): 443 | packet = Packet([DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220]) 444 | self.assertEqual(packet.dst, DST) 445 | self.assertEqual(packet.src, SRC) 446 | self.assertEqual(packet.action, PUMP_PROGRAM) 447 | self.assertEqual(packet.data_length, 4) 448 | self.assertEqual(packet.data, [SET, RPM, 5, 220]) 449 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 210]) 450 | 451 | def test_byte_construction_no_header_multiple_data_bytes_valid_checksum(self): 452 | packet = Packet([DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 210]) 453 | self.assertEqual(packet.dst, DST) 454 | self.assertEqual(packet.src, SRC) 455 | self.assertEqual(packet.action, PUMP_PROGRAM) 456 | self.assertEqual(packet.data_length, 4) 457 | self.assertEqual(packet.data, [SET, RPM, 5, 220]) 458 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 210]) 459 | 460 | def test_byte_construction_no_header_multiple_data_bytes_invalid_checksum(self): 461 | with self.assertRaises(ValueError): 462 | Packet([DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 211]) 463 | 464 | ### Byte Construction, With Header, Multiple Data Bytes 465 | 466 | def test_byte_construction_with_header_multiple_data_bytes_no_checksum(self): 467 | packet = Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220]) 468 | self.assertEqual(packet.dst, DST) 469 | self.assertEqual(packet.src, SRC) 470 | self.assertEqual(packet.action, PUMP_PROGRAM) 471 | self.assertEqual(packet.data_length, 4) 472 | self.assertEqual(packet.data, [SET, RPM, 5, 220]) 473 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 210]) 474 | 475 | def test_byte_construction_with_header_multiple_data_bytes_valid_checksum(self): 476 | packet = Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 210]) 477 | self.assertEqual(packet.dst, DST) 478 | self.assertEqual(packet.src, SRC) 479 | self.assertEqual(packet.action, PUMP_PROGRAM) 480 | self.assertEqual(packet.data_length, 4) 481 | self.assertEqual(packet.data, [SET, RPM, 5, 220]) 482 | self.assertEqual(packet.bytes, [0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 210]) 483 | 484 | def test_byte_construction_with_header_multiple_data_bytes_invalid_checksum(self): 485 | with self.assertRaises(ValueError): 486 | Packet([0xFF, 0x00, 0xFF, PAYLOAD_HEADER, VERSION, DST, SRC, PUMP_PROGRAM, 4, SET, RPM, 5, 220, 2, 211]) 487 | --------------------------------------------------------------------------------