<gh_stars>1-10
from collections.abc import Iterable
from functools import reduce
import json
import re
import struct




class Payload:
    def __init__(self, body):
        self.status = {}
        self.body = body

        # self.dumpBody()

    def dump(self):
        if len(self.status) > 0:
            print(json.dumps(self.status))
        else:
            self.dumpBody()
    
    def dumpBody(self):
        print(' '.join(f'{b:02X}' for b in self.body))


    def getStatus(self):
        # if len(self.status) > 0:
        #     self.dump()
        return self.status

#
# lots of payload cracking things taken from
#   https://docs.google.com/document/d/1M0KMfXfvbszKeqzu6MUF_7yM6KDHk8cZ5nrH1_OUcAc/edit
#

class DatePayload(Payload):
    DAY = { 0x01: "Sunday",
            0x02: "Monday",
            0x04: "Tuesday",
            0x08: "Wednesday",
            0x10: "Thursday",
            0x20: "Friday",
            0x40: "Saturday" }

    def __init__(self, body):
        super().__init__(body)

        try:
            self.status['hour'] = self.body[0]
            self.status['min'] = self.body[1]
            # dow is a bit shift 0x01 << <ordinal DOW - Sun == 0, Sat == 6)
            self.status['dow'] = self.DAY[self.body[2]]
            self.status['day'] = self.body[3]
            self.status['month'] = self.body[4]
            self.status['year'] = self.body[5]
            self.status['adjust'] = self.body[6]
            self.status['dst'] = self.body[7]
        except Exception as err:
            pass


class StatusPayload(Payload):
    # circuit bit-masks for byte 2
    SPA     = 0x01
    AUX1    = 0x02
    AUX2    = 0x04
    AUX3    = 0x08
    POOL    = 0x20
    FEATURE1 = 0x10
    FEATURE2 = 0x40 
    FEATURE3 = 0x80
    # byte 3
    FEATURE4 = 0x01

    # modes for byte 9
    RUN_MODE    = 0x01 # Run Mode (Normal/Service)
    TEMP_UNITS  = 0x04 # Temp Unit (F/C),
    FREEZE_PROTECT = 0x08 # Freeze Protection (Off/On)
    TIMEOUT     = 0x10 # Timeout (Off/On)

    # byte 10
    #HEATER_ON  = 0x0F
    HEATER_ON = 0x0C    # can't find this doc'd anywhere... but mine seems to be 0x0C
    # HEATER_OFF = 0x03 # seems to be wrong... off is off 0x00

    # byte 12
    DELAY = 0x04

    # byte 22 -- masks -- pool is low nibble, spa is high nibble (realy low 2 bits of high nibble)
    # Actually... seems to be just low nibble... high 2 bits for spa, low 2 bits for pool
    HEATER_POOL_OFF     = 0x00  
    HEATER_POOL_EN      = 0x01
    HEATER_POOL_SOLAR_PREF = 0x02
    HEATER_POOL_SOLAR_EN = 0x03

    HEATER_SPA_OFF      = 0x00
    HEATER_SPA_EN       = 0x10

    def __init__(self, body):
        super().__init__(body)

        try:
            self.status['hour'] = self.body[0]
            self.status['min'] = self.body[1]

            self.status['spa'] = (self.body[2] & self.SPA) != 0
            self.status['aux1'] = (self.body[2] & self.AUX1) != 0
            self.status['aux2'] = (self.body[2] & self.AUX2) != 0
            self.status['aux3'] = (self.body[2] & self.AUX3) != 0
            self.status['pool'] = (self.body[2] & self.POOL) != 0
            self.status['feature1'] = (self.body[2] & self.FEATURE1) != 0
            self.status['feature2'] = (self.body[2] & self.FEATURE2) != 0
            self.status['feature3'] = (self.body[2] & self.FEATURE3) != 0

            self.status['feature4'] = (self.body[3] & self.FEATURE4) != 0

            # byte 4 - 8 are 0
            if reduce((lambda x, sum: sum + x), self.body[4:8]) != 0:
                print('unusual bytes 4 - 8 in StatusPayload')

            self.status['runMode'] = self.body[9] & self.RUN_MODE
            self.status['tempUnits'] = self.body[9] & self.TEMP_UNITS
            self.status['freezeProtect'] = self.body[9] & self.FREEZE_PROTECT            
            self.status['timeout'] = self.body[9] & self.TIMEOUT

            # if body[10] != self.HEATER_OFF and body[10] != self.HEATER_ON:
            #     print(f'unusual heater setting in StatusPayload {body[10]:02X}')
            self.status['heater'] = (self.body[10] == self.HEATER_ON)

            if self.body[11] != 0:
                print('unusual byte 11 in StatusPayload')
            
            # if (body[12] & 0x30) != 0x30:
            #     print(f'unusual byte 12 in StatusPayload {body[12]:02X}')
            self.status['delay'] = (self.body[12] & self.DELAY) != 0

            self.status['waterTemp'] = self.body[14] # repeated in body[15]
            self.status['spaTemp'] = self.body[15]
            self.status['airTemp'] = self.body[18]
            self.status['solarTemp'] = self.body[19]

            self.status['poolHeaterMode'] = (self.body[22] & 0x03)
            self.status['spaHeaterMode'] = ((self.body[22] & 0x0C) >> 2)
        
        except Exception as err:
            pass


class PumpPayload(Payload):
    # sample:  
    # 00,10,60,07,0F,0A 02 02 03 11 09 60 00 00 00 00 00 01 13 3B
    def __init__(self, body):
        super().__init__(body)
        try:
            self.status['pumpStarted'] = (self.body[0] & 0x0A) != 0

            (self.status['pumpMode'],
            self.status['pumpState'],
            self.status['pumpWatts'],
            self.status['pumpRPM'] ) = struct.unpack(">BBHHxx", self.body[1:9])

            # print(f"read RPM {self.status['pumpRPM']} from:"); self.dumpBody()

            # there are a lot more bytes... seem to be sequence number...
        except Exception as err:
            pass


class PingPayload(Payload):
    # think this is just a ping to see if the other party is alive
    def __init__(self, body):
        super().__init__(body)
        try:
            if self.body[0] != 0xFF:
                print(f'unkown ping data: {body[0]:02X}')
        except Exception as err:
            pass

    def dump(self):
        pass

class PumpStatus(Payload):
    PUMP_STARTED = 0x0A
    PUMP_STOPPED = 0x04

    def __init__(self, body):
        super().__init__(body)

        try:
            self.status['pumpStarted'] = (self.body[0] & 0x0A) != 0
        except Exception as err:
            pass

class CommandPayload(Payload):
    def __init__(self, body):
        super().__init__(body)
        # print("Command Payload")
        self.dumpBody()

        try:
            # (self.status['pumpRPM'],) = struct.unpack(">H", self.body[-2:])
            # print(f"read RPM {self.status['pumpRPM']} from:"); self.dumpBody()
            pass
        except Exception as err:
            pass

# 24,0F,10,08,0D,4C 4C 3D 55 64 00 00 00 00 00 00 00 00
# temperatures: water water air water-set spa-set
class TempPayload(Payload):
    def __init__(self, body):
        super().__init__(body)

        try:
            # 8 unused bytes... probably solar and other features I don't have
            (self.status['waterTemp'],
            self.status['spaTemp'],
            self.status['airTemp'],
            self.status['poolSetTemp'],
            self.status['spaSetTemp']) = struct.unpack("bbbbbxxxxxxxx", self.body)

        except Exception as err:
            pass

#
# A Command
#
class Command:
    def __init__(self, dst, cmd, commandType=0x24, src=0x21):
        self.type = commandType
        self.destination = dst
        self.source = src
        self.command = cmd

        self.payload = b''

    def getParsedCommand(self):
        parsed = {}
        parsed['type'] = self.type
        parsed['destination'] = self.destination
        parsed['source'] = self.source
        parsed['command'] = self.command

        parsed['payloadLength'] = len(self.payload)
        parsed['payload'] = self.payload

        # parsed['state'] = self.parsePayloadFromFrame(parsed)

        return parsed

# 0x86 - Turn Circuits On and Off 
#
# example:
#   24	10	20	86	2	06 01
#
# will be followed with a ACK from the dest
#
class CircuitChangeCommand(Command):
    CKT_SELECTORS = {
        'spa': 0x01, 
        'aux1': 0x02, 
        'aux2': 0x03, 
        'aux3': 0x04, 
        'feature1': 0x05,         
        'pool': 0x06, 
        'feature2': 0x07, 
        'feature3': 0x08, 
        'feature4': 0x09,
        'HEAT_BOOST': 0x85
    }

    def __init__(self, ckt, onOff, dst=0x10):
        super().__init__(dst, 0x86)

        try:
            circuit = self.CKT_SELECTORS[ckt].to_bytes(1, 'big')
            state = b'\x01' if onOff else b'\x00'

            self.payload = circuit + state
        except Exception as e:
            pass

# 0x88 - Change heating parameters -- set points, enable
#
# example:
#   24	10	20	88	4	52 64 05 00
#
class HeatChangeCommand(Command):
    # modes
    OFF = 0x00
    HEATER = 0x01
    SOLAR = 0x02
    SOLAR_PREF = 0x03

    def __init__(self, poolSet, spaSet, spaMode, poolMode, dst=0x10):
        super().__init__(dst, 0x88)

        mode = (spaMode << 2) | poolMode

        self.payload =  poolSet.to_bytes(1, byteorder='big') + \
                        spaSet.to_bytes(1, byteorder='big') + \
                        mode.to_bytes(1, byteorder='big') + \
                        b'\x00'
        pass


class PentairProtocol:
    RECORD_SEPARATOR = b'\xFF\x00\xFF'
    IDLE_BYTE = b'\xFF'
    START_BYTE = 0xA5

    def __init__(self):
        self.payloads = {
            0x00: { #0x01: CommandPayload,  # this is really an ACKnowledgement of the cmd in the payload
                    #0x04: PingPayload,
                    #0x06: PumpStatus,
                    0x07: PumpPayload },
            0x24: { 0x02: StatusPayload,
                    0x05: DatePayload,
                    0x08: TempPayload }
        }

        self.commandPayloads = {
            'spa': CircuitChangeCommand, 
            'aux1': CircuitChangeCommand, 
            'aux2': CircuitChangeCommand, 
            'aux3': CircuitChangeCommand, 
            'pool': CircuitChangeCommand, 
            'feature1': CircuitChangeCommand, 
            'feature2': CircuitChangeCommand, 
            'feature3': CircuitChangeCommand, 
            'feature4': CircuitChangeCommand
        }
            # 'CircuitChanges': {
            #     'command': CircuitChangeCommand,
            #     'selectors': [ "spa", "aux1", "aux2", "aux3", "pool", "feature1", "feature2", "feature3", "feature4" ]
            # },
            # 'HeatChanges': {
            #     'command': HeatChangeCommand,
            #     'selectors': [ "poolSetTemp", "spaSetTemp", "poolHeaterMode", "spaHeaterMode" ]
            # }

        # "airTemp", "solarTemp", "runMode","tempUnits", "freezeProtect", "timeout", "heater", "delay"
        # "pumpStarted","pumpMode", "pumpState", "pumpWatts", "pumpRPM", "waterTemp", "spaTemp"


        self.resetStats()

    def getStats(self):
        return self.stats

    def resetStats(self):
        self.stats= {   'frameCount': 0,
                        'badFrames': 0,
                        'unprocessedPayloads': 0 }

    # computes checksum for a frame 
    #   if using an incoming frame, strip off the checksum before calling -- e..g f[:-2]
    # otherwise, frame should include the START_BYTE and otherwise be stripped from IDLE_BYTES
    def checkSum(self, frame):
        cs = reduce((lambda x, sum: sum + x), frame)
        return cs

    #
    # validFrame
    #
    #   returns validity of the unpadded frame (that is, remove padding before calling)
    #
    #   A frame has:
    #       START_BYTE      1 bytes -- but this is stripped...
    #       version         1 bytes
    #       data            0+ bytes
    #       checksum        2 bytes Big Endian
    #
    #   frame is validated to have this structure and checksum calculated on frame [:-2]
    #   that is... the full frame less the check sum.  The checksum is the sum of START, version,
    #   and all databytes modulo 2^16
    #
    def validFrame(self, f):
        try:
            f = f.rstrip(self.IDLE_BYTE)
            # valid = f[0] == self.START_BYTE and ((f[-2] << 8) + f[-1]) & 0xFFFF == reduce((lambda x, sum: sum + x), f[:-2])
            valid = f[0] == self.START_BYTE and ((f[-2] << 8) + f[-1]) & 0xFFFF == self.checkSum(f[:-2])
            
            # increment badFrame counter for checksum errors ONLY... not for 'empty' frames
            self.stats['badFrames'] += not valid
        except Exception as e:
            valid = False

        return valid

    #
    #   parsePayloadFromFrame
    #
    def parsePayloadFromFrame(self, frame):
        state = {}

        try:
            if frame['payloadLength'] != len(frame['payload']):
                raise Exception                         

            payload = self.payloads[frame['type']][frame['command']](frame['payload'])
            # self.state.update(payload.getStatus())       # just overwrite ... and get the latest
            state = payload.getStatus()
            # payload.dump()

        except Exception as err:
            self.stats['unprocessedPayloads'] += 1
            # print(err)
        #     print(",".join(list(map((lambda x: f'{x:02X}' if not isinstance(x, Iterable) else ' '.join(f'{b:02X}' for b in x) if len(x) > 0  else ''), list(frame.values())))))
                
        return state


    #
    #   parseFrame
    #
    #   returns a dict with the parsed components of the frame 
    #   DOES NOT PARSE PAYLOAD or SPECIFIC MESSAGE CODES
    #
    def parseFrame(self, f):
        self.stats['frameCount'] += 1
        f = f.rstrip(self.IDLE_BYTE)

        if not self.validFrame(f):
            return {}

        parsed = {}
        try:
            parsed['type'] = f[1]
            parsed['destination'] = f[2]
            parsed['source'] = f[3]
            parsed['command'] = f[4]

            parsed['payloadLength'] = f[5]
            parsed['payload'] = f[6:-2]

            parsed['state'] = self.parsePayloadFromFrame(parsed)

        except Exception as e:
            pass

        return parsed

    # commands are dicts with fields separated out -- just as if parsed
    def createCommand(self, desiredState):
        cmd = {}

        try:
            k = list(desiredState.keys())[0]
            command = self.commandPayloads[k](k, desiredState[k])

            cmd = command.getParsedCommand()

        except Exception as e:
            pass

        return cmd

    def createFrame(self, command):
        frame = self.RECORD_SEPARATOR + self.START_BYTE + self.RECORD_SEPARATOR

        try:
            frame  = b''.join(list(map( lambda x: x.to_bytes(1, 'big'),[    
                self.START_BYTE,           
                command['type'],
                command['destination'],
                command['source'],
                command['command'],
                command['payloadLength']
            ] ))) + command['payload'] # payload is already serialized

            check = self.checkSum(frame)

            frame = self.RECORD_SEPARATOR + frame + check.to_bytes(2, 'big') + self.RECORD_SEPARATOR
        
        except Exception as e:
            pass

        return frame


from collections.abc import Iterable
from functools import reduce
import json
import re
import struct




class Payload:
    def __init__(self, body):
        self.status = {}
        self.body = body

        # self.dumpBody()

    def dump(self):
        if len(self.status) > 0:
            print(json.dumps(self.status))
        else:
            self.dumpBody()
    
    def dumpBody(self):
        print(' '.join(f'{b:02X}' for b in self.body))


    def getStatus(self):
        # if len(self.status) > 0:
        #     self.dump()
        return self.status

#
# lots of payload cracking things taken from
#   https://docs.google.com/document/d/1M0KMfXfvbszKeqzu6MUF_7yM6KDHk8cZ5nrH1_OUcAc/edit
#

class DatePayload(Payload):
    DAY = { 0x01: "Sunday",
            0x02: "Monday",
            0x04: "Tuesday",
            0x08: "Wednesday",
            0x10: "Thursday",
            0x20: "Friday",
            0x40: "Saturday" }

    def __init__(self, body):
        super().__init__(body)

        try:
            self.status['hour'] = self.body[0]
            self.status['min'] = self.body[1]
            # dow is a bit shift 0x01 << <ordinal DOW - Sun == 0, Sat == 6)
            self.status['dow'] = self.DAY[self.body[2]]
            self.status['day'] = self.body[3]
            self.status['month'] = self.body[4]
            self.status['year'] = self.body[5]
            self.status['adjust'] = self.body[6]
            self.status['dst'] = self.body[7]
        except Exception as err:
            pass


class StatusPayload(Payload):
    # circuit bit-masks for byte 2
    SPA     = 0x01
    AUX1    = 0x02
    AUX2    = 0x04
    AUX3    = 0x08
    POOL    = 0x20
    FEATURE1 = 0x10
    FEATURE2 = 0x40 
    FEATURE3 = 0x80
    # byte 3
    FEATURE4 = 0x01

    # modes for byte 9
    RUN_MODE    = 0x01 # Run Mode (Normal/Service)
    TEMP_UNITS  = 0x04 # Temp Unit (F/C),
    FREEZE_PROTECT = 0x08 # Freeze Protection (Off/On)
    TIMEOUT     = 0x10 # Timeout (Off/On)

    # byte 10
    #HEATER_ON  = 0x0F
    HEATER_ON = 0x0C    # can't find this doc'd anywhere... but mine seems to be 0x0C
    # HEATER_OFF = 0x03 # seems to be wrong... off is off 0x00

    # byte 12
    DELAY = 0x04

    # byte 22 -- masks -- pool is low nibble, spa is high nibble (realy low 2 bits of high nibble)
    # Actually... seems to be just low nibble... high 2 bits for spa, low 2 bits for pool
    HEATER_POOL_OFF     = 0x00  
    HEATER_POOL_EN      = 0x01
    HEATER_POOL_SOLAR_PREF = 0x02
    HEATER_POOL_SOLAR_EN = 0x03

    HEATER_SPA_OFF      = 0x00
    HEATER_SPA_EN       = 0x10

    def __init__(self, body):
        super().__init__(body)

        try:
            self.status['hour'] = self.body[0]
            self.status['min'] = self.body[1]

            self.status['spa'] = (self.body[2] & self.SPA) != 0
            self.status['aux1'] = (self.body[2] & self.AUX1) != 0
            self.status['aux2'] = (self.body[2] & self.AUX2) != 0
            self.status['aux3'] = (self.body[2] & self.AUX3) != 0
            self.status['pool'] = (self.body[2] & self.POOL) != 0
            self.status['feature1'] = (self.body[2] & self.FEATURE1) != 0
            self.status['feature2'] = (self.body[2] & self.FEATURE2) != 0
            self.status['feature3'] = (self.body[2] & self.FEATURE3) != 0

            self.status['feature4'] = (self.body[3] & self.FEATURE4) != 0

            # byte 4 - 8 are 0
            if reduce((lambda x, sum: sum + x), self.body[4:8]) != 0:
                print('unusual bytes 4 - 8 in StatusPayload')

            self.status['runMode'] = self.body[9] & self.RUN_MODE
            self.status['tempUnits'] = self.body[9] & self.TEMP_UNITS
            self.status['freezeProtect'] = self.body[9] & self.FREEZE_PROTECT            
            self.status['timeout'] = self.body[9] & self.TIMEOUT

            # if body[10] != self.HEATER_OFF and body[10] != self.HEATER_ON:
            #     print(f'unusual heater setting in StatusPayload {body[10]:02X}')
            self.status['heater'] = (self.body[10] == self.HEATER_ON)

            if self.body[11] != 0:
                print('unusual byte 11 in StatusPayload')
            
            # if (body[12] & 0x30) != 0x30:
            #     print(f'unusual byte 12 in StatusPayload {body[12]:02X}')
            self.status['delay'] = (self.body[12] & self.DELAY) != 0

            self.status['waterTemp'] = self.body[14] # repeated in body[15]
            self.status['spaTemp'] = self.body[15]
            self.status['airTemp'] = self.body[18]
            self.status['solarTemp'] = self.body[19]

            self.status['poolHeaterMode'] = (self.body[22] & 0x03)
            self.status['spaHeaterMode'] = ((self.body[22] & 0x0C) >> 2)
        
        except Exception as err:
            pass


class PumpPayload(Payload):
    # sample:  
    # 00,10,60,07,0F,0A 02 02 03 11 09 60 00 00 00 00 00 01 13 3B
    def __init__(self, body):
        super().__init__(body)
        try:
            self.status['pumpStarted'] = (self.body[0] & 0x0A) != 0

            (self.status['pumpMode'],
            self.status['pumpState'],
            self.status['pumpWatts'],
            self.status['pumpRPM'] ) = struct.unpack(">BBHHxx", self.body[1:9])

            # print(f"read RPM {self.status['pumpRPM']} from:"); self.dumpBody()

            # there are a lot more bytes... seem to be sequence number...
        except Exception as err:
            pass


class PingPayload(Payload):
    # think this is just a ping to see if the other party is alive
    def __init__(self, body):
        super().__init__(body)
        try:
            if self.body[0] != 0xFF:
                print(f'unkown ping data: {body[0]:02X}')
        except Exception as err:
            pass

    def dump(self):
        pass

class PumpStatus(Payload):
    PUMP_STARTED = 0x0A
    PUMP_STOPPED = 0x04

    def __init__(self, body):
        super().__init__(body)

        try:
            self.status['pumpStarted'] = (self.body[0] & 0x0A) != 0
        except Exception as err:
            pass

class CommandPayload(Payload):
    def __init__(self, body):
        super().__init__(body)
        # print("Command Payload")
        self.dumpBody()

        try:
            # (self.status['pumpRPM'],) = struct.unpack(">H", self.body[-2:])
            # print(f"read RPM {self.status['pumpRPM']} from:"); self.dumpBody()
            pass
        except Exception as err:
            pass

# 24,0F,10,08,0D,4C 4C 3D 55 64 00 00 00 00 00 00 00 00
# temperatures: water water air water-set spa-set
class TempPayload(Payload):
    def __init__(self, body):
        super().__init__(body)

        try:
            # 8 unused bytes... probably solar and other features I don't have
            (self.status['waterTemp'],
            self.status['spaTemp'],
            self.status['airTemp'],
            self.status['poolSetTemp'],
            self.status['spaSetTemp']) = struct.unpack("bbbbbxxxxxxxx", self.body)

        except Exception as err:
            pass

#
# A Command
#
class Command:
    def __init__(self, dst, cmd, commandType=0x24, src=0x21):
        self.type = commandType
        self.destination = dst
        self.source = src
        self.command = cmd

        self.payload = b''

    def getParsedCommand(self):
        parsed = {}
        parsed['type'] = self.type
        parsed['destination'] = self.destination
        parsed['source'] = self.source
        parsed['command'] = self.command

        parsed['payloadLength'] = len(self.payload)
        parsed['payload'] = self.payload

        # parsed['state'] = self.parsePayloadFromFrame(parsed)

        return parsed

# 0x86 - Turn Circuits On and Off 
#
# example:
#   24	10	20	86	2	06 01
#
# will be followed with a ACK from the dest
#
class CircuitChangeCommand(Command):
    CKT_SELECTORS = {
        'spa': 0x01, 
        'aux1': 0x02, 
        'aux2': 0x03, 
        'aux3': 0x04, 
        'feature1': 0x05,         
        'pool': 0x06, 
        'feature2': 0x07, 
        'feature3': 0x08, 
        'feature4': 0x09,
        'HEAT_BOOST': 0x85
    }

    def __init__(self, ckt, onOff, dst=0x10):
        super().__init__(dst, 0x86)

        try:
            circuit = self.CKT_SELECTORS[ckt].to_bytes(1, 'big')
            state = b'\x01' if onOff else b'\x00'

            self.payload = circuit + state
        except Exception as e:
            pass

# 0x88 - Change heating parameters -- set points, enable
#
# example:
#   24	10	20	88	4	52 64 05 00
#
class HeatChangeCommand(Command):
    # modes
    OFF = 0x00
    HEATER = 0x01
    SOLAR = 0x02
    SOLAR_PREF = 0x03

    def __init__(self, poolSet, spaSet, spaMode, poolMode, dst=0x10):
        super().__init__(dst, 0x88)

        mode = (spaMode << 2) | poolMode

        self.payload =  poolSet.to_bytes(1, byteorder='big') + \
                        spaSet.to_bytes(1, byteorder='big') + \
                        mode.to_bytes(1, byteorder='big') + \
                        b'\x00'
        pass


class PentairProtocol:
    RECORD_SEPARATOR = b'\xFF\x00\xFF'
    IDLE_BYTE = b'\xFF'
    START_BYTE = 0xA5

    def __init__(self):
        self.payloads = {
            0x00: { #0x01: CommandPayload,  # this is really an ACKnowledgement of the cmd in the payload
                    #0x04: PingPayload,
                    #0x06: PumpStatus,
                    0x07: PumpPayload },
            0x24: { 0x02: StatusPayload,
                    0x05: DatePayload,
                    0x08: TempPayload }
        }

        self.commandPayloads = {
            'spa': CircuitChangeCommand, 
            'aux1': CircuitChangeCommand, 
            'aux2': CircuitChangeCommand, 
            'aux3': CircuitChangeCommand, 
            'pool': CircuitChangeCommand, 
            'feature1': CircuitChangeCommand, 
            'feature2': CircuitChangeCommand, 
            'feature3': CircuitChangeCommand, 
            'feature4': CircuitChangeCommand
        }
            # 'CircuitChanges': {
            #     'command': CircuitChangeCommand,
            #     'selectors': [ "spa", "aux1", "aux2", "aux3", "pool", "feature1", "feature2", "feature3", "feature4" ]
            # },
            # 'HeatChanges': {
            #     'command': HeatChangeCommand,
            #     'selectors': [ "poolSetTemp", "spaSetTemp", "poolHeaterMode", "spaHeaterMode" ]
            # }

        # "airTemp", "solarTemp", "runMode","tempUnits", "freezeProtect", "timeout", "heater", "delay"
        # "pumpStarted","pumpMode", "pumpState", "pumpWatts", "pumpRPM", "waterTemp", "spaTemp"


        self.resetStats()

    def getStats(self):
        return self.stats

    def resetStats(self):
        self.stats= {   'frameCount': 0,
                        'badFrames': 0,
                        'unprocessedPayloads': 0 }

    # computes checksum for a frame 
    #   if using an incoming frame, strip off the checksum before calling -- e..g f[:-2]
    # otherwise, frame should include the START_BYTE and otherwise be stripped from IDLE_BYTES
    def checkSum(self, frame):
        cs = reduce((lambda x, sum: sum + x), frame)
        return cs

    #
    # validFrame
    #
    #   returns validity of the unpadded frame (that is, remove padding before calling)
    #
    #   A frame has:
    #       START_BYTE      1 bytes -- but this is stripped...
    #       version         1 bytes
    #       data            0+ bytes
    #       checksum        2 bytes Big Endian
    #
    #   frame is validated to have this structure and checksum calculated on frame [:-2]
    #   that is... the full frame less the check sum.  The checksum is the sum of START, version,
    #   and all databytes modulo 2^16
    #
    def validFrame(self, f):
        try:
            f = f.rstrip(self.IDLE_BYTE)
            # valid = f[0] == self.START_BYTE and ((f[-2] << 8) + f[-1]) & 0xFFFF == reduce((lambda x, sum: sum + x), f[:-2])
            valid = f[0] == self.START_BYTE and ((f[-2] << 8) + f[-1]) & 0xFFFF == self.checkSum(f[:-2])
            
            # increment badFrame counter for checksum errors ONLY... not for 'empty' frames
            self.stats['badFrames'] += not valid
        except Exception as e:
            valid = False

        return valid

    #
    #   parsePayloadFromFrame
    #
    def parsePayloadFromFrame(self, frame):
        state = {}

        try:
            if frame['payloadLength'] != len(frame['payload']):
                raise Exception                         

            payload = self.payloads[frame['type']][frame['command']](frame['payload'])
            # self.state.update(payload.getStatus())       # just overwrite ... and get the latest
            state = payload.getStatus()
            # payload.dump()

        except Exception as err:
            self.stats['unprocessedPayloads'] += 1
            # print(err)
        #     print(",".join(list(map((lambda x: f'{x:02X}' if not isinstance(x, Iterable) else ' '.join(f'{b:02X}' for b in x) if len(x) > 0  else ''), list(frame.values())))))
                
        return state


    #
    #   parseFrame
    #
    #   returns a dict with the parsed components of the frame 
    #   DOES NOT PARSE PAYLOAD or SPECIFIC MESSAGE CODES
    #
    def parseFrame(self, f):
        self.stats['frameCount'] += 1
        f = f.rstrip(self.IDLE_BYTE)

        if not self.validFrame(f):
            return {}

        parsed = {}
        try:
            parsed['type'] = f[1]
            parsed['destination'] = f[2]
            parsed['source'] = f[3]
            parsed['command'] = f[4]

            parsed['payloadLength'] = f[5]
            parsed['payload'] = f[6:-2]

            parsed['state'] = self.parsePayloadFromFrame(parsed)

        except Exception as e:
            pass

        return parsed

    # commands are dicts with fields separated out -- just as if parsed
    def createCommand(self, desiredState):
        cmd = {}

        try:
            k = list(desiredState.keys())[0]
            command = self.commandPayloads[k](k, desiredState[k])

            cmd = command.getParsedCommand()

        except Exception as e:
            pass

        return cmd

    def createFrame(self, command):
        frame = self.RECORD_SEPARATOR + self.START_BYTE + self.RECORD_SEPARATOR

        try:
            frame  = b''.join(list(map( lambda x: x.to_bytes(1, 'big'),[    
                self.START_BYTE,           
                command['type'],
                command['destination'],
                command['source'],
                command['command'],
                command['payloadLength']
            ] ))) + command['payload'] # payload is already serialized

            check = self.checkSum(frame)

            frame = self.RECORD_SEPARATOR + frame + check.to_bytes(2, 'big') + self.RECORD_SEPARATOR
        
        except Exception as e:
            pass

        return frame
# self.dumpBody() # if len(self.status) > 0: #     self.dump() # # lots of payload cracking things taken from #   https://docs.google.com/document/d/1M0KMfXfvbszKeqzu6MUF_7yM6KDHk8cZ5nrH1_OUcAc/edit # # dow is a bit shift 0x01 << <ordinal DOW - Sun == 0, Sat == 6) # circuit bit-masks for byte 2 # byte 3 # modes for byte 9 # Run Mode (Normal/Service) # Temp Unit (F/C), # Freeze Protection (Off/On) # Timeout (Off/On) # byte 10 #HEATER_ON  = 0x0F # can't find this doc'd anywhere... but mine seems to be 0x0C # HEATER_OFF = 0x03 # seems to be wrong... off is off 0x00 # byte 12 # byte 22 -- masks -- pool is low nibble, spa is high nibble (realy low 2 bits of high nibble) # Actually... seems to be just low nibble... high 2 bits for spa, low 2 bits for pool # byte 4 - 8 are 0 # if body[10] != self.HEATER_OFF and body[10] != self.HEATER_ON: #     print(f'unusual heater setting in StatusPayload {body[10]:02X}') # if (body[12] & 0x30) != 0x30: #     print(f'unusual byte 12 in StatusPayload {body[12]:02X}') # repeated in body[15] # sample: # 00,10,60,07,0F,0A 02 02 03 11 09 60 00 00 00 00 00 01 13 3B # print(f"read RPM {self.status['pumpRPM']} from:"); self.dumpBody() # there are a lot more bytes... seem to be sequence number... # think this is just a ping to see if the other party is alive # print("Command Payload") # (self.status['pumpRPM'],) = struct.unpack(">H", self.body[-2:]) # print(f"read RPM {self.status['pumpRPM']} from:"); self.dumpBody() # 24,0F,10,08,0D,4C 4C 3D 55 64 00 00 00 00 00 00 00 00 # temperatures: water water air water-set spa-set # 8 unused bytes... probably solar and other features I don't have # # A Command # # parsed['state'] = self.parsePayloadFromFrame(parsed) # 0x86 - Turn Circuits On and Off # # example: #   24	10	20	86	2	06 01 # # will be followed with a ACK from the dest # # 0x88 - Change heating parameters -- set points, enable # # example: #   24	10	20	88	4	52 64 05 00 # # modes #0x01: CommandPayload,  # this is really an ACKnowledgement of the cmd in the payload #0x04: PingPayload, #0x06: PumpStatus, # 'CircuitChanges': { #     'command': CircuitChangeCommand, #     'selectors': [ "spa", "aux1", "aux2", "aux3", "pool", "feature1", "feature2", "feature3", "feature4" ] # }, # 'HeatChanges': { #     'command': HeatChangeCommand, #     'selectors': [ "poolSetTemp", "spaSetTemp", "poolHeaterMode", "spaHeaterMode" ] # } # "airTemp", "solarTemp", "runMode","tempUnits", "freezeProtect", "timeout", "heater", "delay" # "pumpStarted","pumpMode", "pumpState", "pumpWatts", "pumpRPM", "waterTemp", "spaTemp" # computes checksum for a frame #   if using an incoming frame, strip off the checksum before calling -- e..g f[:-2] # otherwise, frame should include the START_BYTE and otherwise be stripped from IDLE_BYTES # # validFrame # #   returns validity of the unpadded frame (that is, remove padding before calling) # #   A frame has: #       START_BYTE      1 bytes -- but this is stripped... #       version         1 bytes #       data            0+ bytes #       checksum        2 bytes Big Endian # #   frame is validated to have this structure and checksum calculated on frame [:-2] #   that is... the full frame less the check sum.  The checksum is the sum of START, version, #   and all databytes modulo 2^16 # # valid = f[0] == self.START_BYTE and ((f[-2] << 8) + f[-1]) & 0xFFFF == reduce((lambda x, sum: sum + x), f[:-2]) # increment badFrame counter for checksum errors ONLY... not for 'empty' frames # #   parsePayloadFromFrame # # self.state.update(payload.getStatus())       # just overwrite ... and get the latest # payload.dump() # print(err) #     print(",".join(list(map((lambda x: f'{x:02X}' if not isinstance(x, Iterable) else ' '.join(f'{b:02X}' for b in x) if len(x) > 0  else ''), list(frame.values()))))) # #   parseFrame # #   returns a dict with the parsed components of the frame #   DOES NOT PARSE PAYLOAD or SPECIFIC MESSAGE CODES # # commands are dicts with fields separated out -- just as if parsed # payload is already serialized