# Devices.py  30/09/2015  D.J.Whale
#
# Information about specific Energenie devices
# This table is mostly reverse-engineered from various websites and web catalogues.

##from lifecycle import *

import time
import copy
import random
import sys
import traceback
try:
    # Python 2
    import OnAir
    import OpenThings
except ImportError:
    # Python 3
    from . import OnAir
    from . import OpenThings

# This level of indirection allows easy mocking for testing
ook_interface = OnAir.TwoBitAirInterface()
fsk_interface = OnAir.OpenThingsAirInterface()


MFRID_ENERGENIE                  = 0x04
MFRID                            = MFRID_ENERGENIE

##PRODUCTID_MIHO001               =        #         Home Hub
##PRODUCTID_MIHO002               =        #         OOK Control only
##PRODUCTID_MIHO003               = 0x0?   #         Hand Controller
PRODUCTID_MIHO004                = 0x01   #         Monitor only
PRODUCTID_MIHO005                = 0x02   #         Adaptor Plus
PRODUCTID_MIHO006                = 0x05   #         House Monitor
##PRODUCTID_MIHO007               = 0x0?   #         Double Wall Socket White
##PRODUCTID_MIHO008               = 0x0?   #         OOK: Single light switch white
##PRODUCTID_MIHO009 not used
##PRODUCTID_MIHO010 not used
##PRODUCTID_MIHO011 not used
##PRODUCTID_MIHO012 not used
PRODUCTID_MIHO013                = 0x03   #         eTRV
##PRODUCTID_MIHO014                       # OOK In-line Relay
##PRODUCTID_MIHO015 not used
##PRODUCTID_MIHO016 not used
##PRODUCTID_MIHO017
##PRODUCTID_MIHO018
##PRODUCTID_MIHO019
##PRODUCTID_MIHO020
##PRODUCTID_MIHO021               = 0x0?   #         Double Wall Socket Nickel
##PRODUCTID_MIHO022               = 0x0?   #         Double Wall Socket Chrome
##PRODUCTID_MIHO023               = 0x0?   #         Double Wall Socket Brushed Steel
##PRODUCTID_MIHO024               = 0x0?   #         OOK:Style Light Nickel
##PRODUCTID_MIHO025               = 0x0?   #         OOK:Style Light Chrome
##PRODUCTID_MIHO026               = 0x0?   #         OOK:Style Light Steel
##PRODUCTID_MIHO027 starter pack bundle
##PRODUCTID_MIHO028 eco starter pack
##PRODUCTID_MIHO029 heating bundle
##PRODUCTID_MIHO030 not used
##PRODUCTID_MIHO031 not used
PRODUCTID_MIHO032                 = 0x0C  # FSK motion sensor
PRODUCTID_MIHO033                 = 0x0D    # FSK open sensor
##PRODUCTID_MIHO034 not used
##PRODUCTID_MIHO035 not used
##PRODUCTID_MIHO036 not used
##PRODUCTID_MIHO037 Adaptor Plus Bundle
##PRODUCTID_MIHO038 2-gang socket Bundle
##PRODUCTID_MIHO039 2-gang socket Bundle black nickel
##PRODUCTID_MIHO040 2-gang socket Bundle chrome
##PRODUCTID_MIHO041 2-gang socket Bundle stainless steel

# Default keys for OpenThings encryption and decryption
CRYPT_PID                        = 242
CRYPT_PIP                        = 0x0100

# OpenThings does not support a broadcast id,
# but Energenie added one for their MiHome Adaptors.
# This makes simple discovery possible.
BROADCAST_ID                     = 0xFFFFFF # Energenie broadcast


#----- DEFINED MESSAGE TEMPLATES ----------------------------------------------

SWITCH = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO005,
        "encryptPIP":  CRYPT_PIP,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      True,
            "paramid": OpenThings.PARAM_SWITCH_STATE,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        }
    ]
}

MIHO013_IDENTIFY = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO013,
        "encryptPIP":  CRYPT_PID,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      True,
            "paramid": OpenThings.PARAM_IDENTIFY,
            "typeid":  OpenThings.Value.UINT,
            "length":  0,
        }
    ]
}

MIHO013_EXERCISE = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO013,
        "encryptPIP":  CRYPT_PIP,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      True,
            "paramid": OpenThings.PARAM_EXERCISE,
            "typeid":  OpenThings.Value.UINT,
            "length":  0
        }
    ]
}

MIHO013_BATTERY_LEVEL = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO013,
        "encryptPIP":  CRYPT_PID,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      True,
            "paramid": OpenThings.PARAM_BATTERY_LEVEL, #OpenThings.PARAM_IDENTIFY,
            "typeid":  OpenThings.Value.UINT,
            "length":  0,
        }
    ]
}

MIHO013_DIAGNOSTICS = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO013,
        "encryptPIP":  CRYPT_PID,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      True,
            "paramid": OpenThings.PARAM_DIAGNOSTICS,
            "typeid":  OpenThings.Value.UINT,
            "length":  0,
        }
    ]
}

MIHO013_SET_TEMPERATURE = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO013,
        "encryptPIP":  CRYPT_PID,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      True,
            "paramid": OpenThings.PARAM_TEMPERATURE,
            "typeid":  OpenThings.Value.SINT_BP8,
            "length":  2,
            "value":   0 # FILL IN
        }
    ]
}

MIHO013_SET_VALVE_POSITION = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO013,
        "encryptPIP":  CRYPT_PID,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      True,
            "paramid": OpenThings.PARAM_VALVE_POSITION,
            "typeid":  0x01,
            "length":  1,
            "value":   0 # FILL IN
        }
    ]
}




JOIN_REQ = {
    "header": {
        "mfrid":       0, # FILL IN
        "productid":   0, # FILL IN
        "encryptPIP":  CRYPT_PIP,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_JOIN,
            "typeid":  OpenThings.Value.UINT,
            "length":  0
        }
    ]
}

JOIN_ACK = {
    "header": {
        "mfrid":       MFRID_ENERGENIE, # FILL IN
        "productid":   0, # FILL IN
        "encryptPIP":  CRYPT_PIP,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_JOIN,
            "typeid":  OpenThings.Value.UINT,
            "length":  0
        }
    ]
}

REGISTERED_SENSOR = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   0, # FILL IN
        "encryptPIP":  CRYPT_PIP,
        "sensorid":    0 # FILL IN
    }
}

MIHO005_REPORT = {
    "header": {
        "mfrid":       MFRID_ENERGENIE,
        "productid":   PRODUCTID_MIHO005,
        "encryptPIP":  CRYPT_PIP,
        "sensorid":    0 # FILL IN
    },
    "recs": [
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_SWITCH_STATE,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        },
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_VOLTAGE,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        },
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_CURRENT,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        },
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_FREQUENCY,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        },
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_REAL_POWER,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        },
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_REACTIVE_POWER,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        },
        {
            "wr":      False,
            "paramid": OpenThings.PARAM_APPARENT_POWER,
            "typeid":  OpenThings.Value.UINT,
            "length":  1,
            "value":   0 # FILL IN
        },

    ]
}


#----- CONTRACT WITH AIR-INTERFACE --------------------------------------------

# this might be a real air_interface (a radio), or an adaptor interface
# (a message scheduler with a queue).
#
#   synchronous send
#   synchronous receive
#TODO: asynchronous send (deferred)    - implies a callback on 'done, fail, timeout'
#TODO: asynchronous receive (deferred) - implies a callback on 'done, fail, timeout'

# air_interface has:
#   configure(parameters)
#   send(payload)
#   send(payload, parameters)
#   receive() -> (radio_measurements, address, payload)


#----- NEW DEVICE CLASSES -----------------------------------------------------

class Device():
    """A generic connected device abstraction"""
    def __init__(self, device_id=None, air_interface=None):
        self.air_interface = air_interface
        self.device_id = self.parse_device_id(device_id)
        class RadioConfig(): pass
        self.radio_config = RadioConfig()
        class Capabilities(): pass
        self.capabilities = Capabilities()
        self.updated_cb = None
        self.rxseq = 0
        self.lastHandledMessage = 0

    def get_config(self):
        raise RuntimeError("There is no configuration for a base Device")

    @staticmethod
    def parse_device_id(device_id):
        """device_id could be a number, a hex string or a decimal string"""
        ##print("**** parsing: %s" % str(device_id))
        if device_id == None:
            raise ValueError("device_id is None, not allowed")

        if type(device_id) == int:
            return device_id # does not need to be parsed

        if type(device_id) == tuple or type(device_id) == list:
            # each part of the tuple could be encoded
            res = []
            for p in device_id:
                res.append(Device.parse_device_id(p))
            #TODO: could usefully convert to tuple here to be helpful
            return res

        if type(device_id) == str:
            # could be hex or decimal or strtuple or strlist
            if device_id == "":
                raise ValueError("device_id is blank, not allowed")
            elif device_id.startswith("0x"):
                return int(device_id, 16)
            elif device_id[0] == '(' and device_id[-1] == ')':
                ##print("**** parse tuple")
                inner = device_id[1:-1]
                parts = inner.split(',')
                ##print(parts)
                res = []
                for p in parts:
                    res.append(Device.parse_device_id(p))
                ##print(res)
                return res

            elif device_id[0] == '[' and device_id[-1] == ']':
                ##print("**** parse list")
                inner = device_id[1:-1]
                parts = inner.split(',')
                ##print(parts)
                res = []
                for p in parts:
                    res.append(Device.parse_device_id(p))
                #TODO: could usefully change to tuple here
                ##print(res)
                return res
            else:
                return int(device_id, 10)

        else:
            raise ValueError("device_id unsupported type or format, got: %s %s" % (type(device_id), str(device_id)))


    def has_switch(self):
        return hasattr(self.capabilities, "switch")

    def can_send(self):
        return hasattr(self.capabilities, "send")

    def can_receive(self):
        return hasattr(self.capabilities, "receive")

    def get_radio_config(self):
        return self.radio_config

    def get_last_receive_time(self): # ->timestamp
        """The timestamp of the last time any message was received by this device"""
        return self.last_receive_time

    def get_next_receive_time(self): # -> timestamp
        """An estimate of the next time we expect a message from this device"""
        pass

    def get_readings_summary(self):
        """Try to get a terse summary of all present readings"""

        try:
            r = self.readings
        except AttributeError:
            return "(no readings)"

        def shortname(name):
            parts = name.split('_')
            sn = ""
            for p in parts:
                sn += p[0].upper()
            return sn


        line = ""
        for rname in dir(self.readings):
            if not rname.startswith("__"):
                value = getattr(self.readings, rname)
                line += "%s:%s " % (shortname(rname), str(value))

        return line

        # for each reading
        #   call get_x to get the reading
        #   think of a very short name, perhaps first letter of reading name?
        #   add it to a terse string
        # return the string

    def get_receive_count(self):
        return self.rxseq

    def incoming_message(self, payload):
        """Entry point for a message to be processed"""
        #This is the base-class entry point, don't  override this, but override handle_message
        self.rxseq += 1
        self.handle_message(payload)
        if self.updated_cb != None:
            self.updated_cb(self, payload)
        self.lastHandledMessage=time.time()

    def handle_message(self, payload):
        """Default handling for a new message"""
        print("incoming(unhandled): %s" % payload)

    def send_message(self, payload):
        print("send_message %s" % payload)
        # A raw device has no knowledge of how to send, the sub class provides that.

    def when_updated(self, callback):
        """Provide a callback handler to be called when a new message arrives"""
        self.updated_cb = callback
        # signature: update(self, message)

    def __repr__(self):
        return "Device()"


class EnergenieDevice(Device):
    """An abstraction for any kind of Energenie connected device"""
    def __init__(self, device_id=None, air_interface=None):
        Device.__init__(self, device_id, air_interface)

    def get_device_id(self): # -> id:int
        return self.device_id

    def __repr__(self):
        return "EnergenieDevice(%s)" % str(self.device_id)


class LegacyDevice(EnergenieDevice):
    DEFAULT_HOUSE_ADDRESS = 0x6C6C6

    """An abstraction for Energenie green button legacy OOK devices"""
    def __init__(self, device_id=None, air_interface=None):
        if air_interface == None:
            air_interface == ook_interface
        if device_id == None:
            device_id = (LegacyDevice.DEFAULT_HOUSE_ADDRESS, 1)
        elif type(device_id) == int:
            device_id = (LegacyDevice.DEFAULT_HOUSE_ADDRESS, device_id)
        elif type(device_id) == tuple and device_id[0] ==  None:
            device_id = (LegacyDevice.DEFAULT_HOUSE_ADDRESS, device_id[1])

        EnergenieDevice.__init__(self, device_id, ook_interface)
        #TODO: These are now just be implied by the ook_interface adaptor
        ##self.radio_config.frequency  = 433.92
        ##self.radio_config.modulation = "OOK"
        ##self.radio_config.codec      = "4bit"

    def __repr__(self):
        return "LegacyDevice(%s)" % str(self.device_id)

    def get_config(self):
        """Get the persistable config, enough to reconstruct this class from a factory"""
        return {
            "type":            self.__class__.__name__,
            "device_id":       self.device_id
        }


    def send_message(self, payload):
        if self.air_interface != None:
            self.air_interface.send(payload, radio_config=self.radio_config)
        else:
            d = self.device_id
            print("send_message(mock[%s]):%s" % (str(d), payload))


class MiHomeDevice(EnergenieDevice):
    """An abstraction for Energenie new style MiHome FSK devices"""
    def __init__(self, device_id=None, air_interface=None):
        if air_interface == None:
            air_interface = fsk_interface
        EnergenieDevice.__init__(self, device_id, air_interface)
        #TODO: These are now implied by the air_interface adaptor
        ##self.radio_config.frequency  = 433.92
        ##self.radio_config.modulation = "FSK"
        ##self.radio_config.codec      = "OpenThings"
        self.manufacturer_id         = MFRID_ENERGENIE
        self.product_id              = None

        #Different devices might have different PIP's
        #if we are cycling codes on each message?
        ##self.config.encryptPID = CRYPT_PID
        ##self.config.encryptPIP = CRYPT_PIP

    def get_config(self):
        """Get the persistable config, enough to reconstruct this class from a factory"""
        return {
            "type":            self.__class__.__name__,
            ##"manufacturer_id": self.manufacturer_id, # not needed, known by class
            ##"product_id":      self.product_id, # not needed, known by class
            "device_id":       self.device_id
        }

    def __repr__(self):
        return "MiHomeDevice(%s,%s,%s)" % (str(self.manufacturer_id), str(self.product_id), str(self.device_id))

    def get_manufacturer_id(self): # -> id:int
        return self.manufacturer_id

    def get_product_id(self): # -> id:int
        return self.product_id

    @staticmethod
    def get_join_req(mfrid, productid, deviceid):
        """Used for testing, synthesises a JOIN_REQ message from this device"""
        msg = OpenThings.Message(JOIN_REQ)
        msg["header_mfrid"]     = mfrid
        msg["header_productid"] = productid
        msg["header_sensorid"]  = deviceid
        return msg

    def join_ack(self):
        """Send a join-ack to the real device"""
        print "send join ack"
        #msg = OpenThings.Message(header_mfrid=MFRID_ENERGENIE, header_productid=self.product_id, header_sensorid=self.device_id)
        #msg[OpenThings.PARAM_JOIN] = {"wr":False, "typeid":OpenThings.Value.UINT, "length":0}
        #self.send_message(msg)
        
        payload = OpenThings.Message(JOIN_ACK)
        payload.set(header_productid=self.product_id,
                    header_sensorid=self.device_id)
        self.send_message(payload)
        
    ##def handle_message(self, payload):
    #override for any specific handling

    def send_message(self, payload, encoded=False):
        #TODO: interface with air_interface
        #is payload a pydict with header at this point, and we have to call OpenThings.encode?
        #should the encode be done here, or in the air_interface adaptor?

        #TODO: at what point is the payload turned into a pydict?
        #TODO: We know it's going over OpenThings,
        #do we call OpenThings.encode(payload) here?
        #also OpenThings.encrypt() - done by encode() as default
        if self.air_interface != None:
            #TODO: might want to send the config, either as a send parameter,
            #or by calling air_interface.configure() first?
            self.air_interface.send(payload, encoded=encoded, radio_config=self.radio_config)
        else:
            m = self.manufacturer_id
            p = self.product_id
            d = self.device_id
            print("send_message(mock[%s %s %s]):%s" % (str(m), str(p), str(d), payload))


#------------------------------------------------------------------------------

class OOKSwitch(LegacyDevice):
    """Any OOK controlled switch"""
    def __init__(self, device_id, air_interface=None):
        LegacyDevice.__init__(self, device_id, air_interface)
        self.radio_config.inner_times = 8
        self.capabilities.switch = True
        self.capabilities.receive = True

    def __repr__(self):
        return "OOKSwitch(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))


    def turn_on(self):
        #TODO: should this be here, or in LegacyDevice??
        #addressing should probably be in LegacyDevice
        #child devices might interpret the command differently
        payload = {
            "house_address":  self.device_id[0],
            "device_index":   self.device_id[1],
            "on":             True
        }
        self.send_message(payload)

    def turn_off(self):
        #TODO: should this be here, or in LegacyDevice???
        #addressing should probably be in LegacyDevice
        #child devices might interpret the command differently
        payload = {
            "house_address":  self.device_id[0],
            "device_index":   self.device_id[1],
            "on":             False
        }
        self.send_message(payload)

    def set_switch(self, state):
        if state:
            self.turn_on()
        else:
            self.turn_off()


class ENER002(OOKSwitch):
    """A green button switch"""
    def __repr__(self):
        return "ENER002(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))


class MIHO002(OOKSwitch):
    """A purple button MiHome switch"""
    def __repr__(self):
        return "MIHO002(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))


class MIHO014(OOKSwitch):
    """Energenie 3kW switchable relay"""
    def __repr__(self):
        return "MIHO014(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))


#------------------------------------------------------------------------------

class MiHomeLight(LegacyDevice):
    """Base for all MiHomeLight variants. Receive only OOK device"""
    def __init__(self, device_id, air_interface=None):
        LegacyDevice.__init__(self, device_id, air_interface)
        self.radio_config.inner_times = 75
        self.capabilities.switch = True
        self.capabilities.receive = True

    def __repr__(self):
        return "MiHomeLight(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))

    def turn_on(self):
        #TODO: should this be here, or in LegacyDevice??
        #addressing should probably be in LegacyDevice
        #child devices might interpret the command differently
        payload = {
            "house_address":  self.device_id[0],
            "device_index":   self.device_id[1],
            "on":             True
        }
        self.send_message(payload)

    def turn_off(self):
        #TODO: should this be here, or in LegacyDevice???
        #addressing should probably be in LegacyDevice
        #child devices might interpret the command differently
        payload = {
            "house_address":  self.device_id[0],
            "device_index":   self.device_id[1],
            "on":             False
        }
        self.send_message(payload)

    def set_switch(self, state):
        if state:
            self.turn_on()
        else:
            self.turn_off()


class MIHO008(MiHomeLight):
    """White finish"""
    def __repr__(self):
        return "MIHO008(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))

class MIHO024(MiHomeLight):
    """Black Nickel Finish"""
    def __repr__(self):
        return "MIHO024(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))

class MIHO025(MiHomeLight):
    """Chrome Finish"""
    def __repr__(self):
        return "MIHO025(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))

class MIHO026(MiHomeLight):
    """Brushed Steel Finish"""
    def __repr__(self):
        return "MIHO026(%s,%s)" % (str(hex(self.device_id[0])), str(hex(self.device_id[1])))


#------------------------------------------------------------------------------

class MIHO004(MiHomeDevice):
    """Energenie Monitor-only Adaptor"""
    def __init__(self, device_id, air_interface=None):
        MiHomeDevice.__init__(self, device_id, air_interface)
        self.product_id = PRODUCTID_MIHO004
        class Readings():
            voltage        = None
            frequency      = None
            current        = None
            apparent_power = None
            reactive_power = None
            real_power     = None
        self.readings = Readings()
        self.radio_config.inner_times = 4
        self.capabilities.send = True
        self.capabilities.switch = True

    def __repr__(self):
        return "MIHO004(%s)" % str(hex(self.device_id))

    @staticmethod
    def get_join_req(deviceid):
        """Get a synthetic join request from this device, for testing"""
        return MiHomeDevice.get_join_req(MFRID_ENERGENIE, PRODUCTID_MIHO004, deviceid)

    def handle_message(self, payload):
        ##print("MIHO005 new data %s %s" % (self.device_id, payload))
        for rec in payload["recs"]:
            paramid = rec["paramid"]
            #TODO: consider making this table driven and allowing our base class to fill our readings in for us
            #  then just define the mapping table in __init__ (i.e. paramid->Readings field name)
            value = rec["value"]
            if paramid == OpenThings.PARAM_VOLTAGE:
                self.readings.voltage = value
            elif paramid == OpenThings.PARAM_CURRENT:
                self.readings.current = value
            elif paramid == OpenThings.PARAM_REAL_POWER:
                self.readings.real_power = value
            elif paramid == OpenThings.PARAM_APPARENT_POWER:
                self.readings.apparent_power = value
            elif paramid == OpenThings.PARAM_REACTIVE_POWER:
                self.readings.reactive_power = value
            elif paramid == OpenThings.PARAM_FREQUENCY:
                self.readings.frequency = value
            else:
                try:
                    param_name = OpenThings.param_info[paramid]['n'] # name
                except:
                    param_name = "UNKNOWN_%s" % str(hex(paramid))
                print("unwanted paramid: %s" % param_name)

    def get_readings(self): # -> readings:pydict
        """A way to get all readings as a single consistent set"""
        return self.readings

    def get_voltage(self): # -> voltage:float
        """Last stored state of voltage reading, None if unknown"""
        if self.readings.voltage == None:
            raise RuntimeError("No voltage reading received yet")
        return self.readings.voltage

    def get_frequency(self): # -> frequency:float
        """Last stored state of frequency reading, None if unknown"""
        if self.readings.frequency == None:
            raise RuntimeError("No frequency reading received yet")
        return self.readings.frequency

    def get_apparent_power(self): # ->power:float
        """Last stored state of apparent power reading, None if unknown"""
        if self.readings.apparent_power == None:
            raise RuntimeError("No apparent power reading received yet")
        return self.readings.apparent_power

    def get_reactive_power(self): # -> power:float
        """Last stored state of reactive power reading, None if unknown"""
        if self.readings.reactive_power == None:
            raise RuntimeError("No reactive power reading received yet")
        return self.readings.reactive_power

    def get_real_power(self): #-> power:float
        """Last stored state of real power reading, None if unknown"""
        if self.readings.real_power == None:
            raise RuntimeError("No real power reading received yet")
        return self.readings.real_power


#------------------------------------------------------------------------------

class MIHO005(MiHomeDevice):
    """An Energenie MiHome Adaptor Plus"""
    def __init__(self, device_id, air_interface=None):
        MiHomeDevice.__init__(self, device_id, air_interface)
        self.product_id = PRODUCTID_MIHO005
        class Readings():
            switch         = None
            voltage        = None
            frequency      = None
            current        = None
            apparent_power = None
            reactive_power = None
            real_power     = None
        self.readings = Readings()
        self.radio_config.inner_times = 4
        self.capabilities.send = True
        self.capabilities.receive = True
        self.capabilities.switch = True

    def __repr__(self):
        return "MIHO005(%s)" % str(hex(self.device_id))

    @staticmethod
    def get_join_req(deviceid):
        """Get a synthetic join request from this device, for testing"""
        return MiHomeDevice.get_join_req(MFRID_ENERGENIE, PRODUCTID_MIHO004, deviceid)

    def handle_message(self, payload):
        #print("MIHO005 new data %s %s" % (self.device_id, payload))
        for rec in payload["recs"]:
            paramid = rec["paramid"]
            #TODO: consider making this table driven and allowing our base class to fill our readings in for us
            #  then just define the mapping table in __init__ (i.e. paramid->Readings field name)
            value = rec["value"]
            if paramid == OpenThings.PARAM_SWITCH_STATE:
                self.readings.switch = ((value == True) or (value != 0))
            elif paramid == OpenThings.PARAM_VOLTAGE:
                self.readings.voltage = value
            elif paramid == OpenThings.PARAM_CURRENT:
                self.readings.current = value
            elif paramid == OpenThings.PARAM_REAL_POWER:
                self.readings.real_power = value
            elif paramid == OpenThings.PARAM_APPARENT_POWER:
                self.readings.apparent_power = value
            elif paramid == OpenThings.PARAM_REACTIVE_POWER:
                self.readings.reactive_power = value
            elif paramid == OpenThings.PARAM_FREQUENCY:
                self.readings.frequency = value
            else:
                try:
                    param_name = OpenThings.param_info[paramid]['n'] # name
                except:
                    param_name = "UNKNOWN_%s" % str(hex(paramid))
                print("unwanted paramid: %s" % param_name)

    def get_readings(self): # -> readings:pydict
        """A way to get all readings as a single consistent set"""
        return self.readings

    def turn_on(self):
        #TODO: header construction should be in MiHomeDevice as it is shared?
        payload = OpenThings.Message(SWITCH)
        payload.set(header_productid=self.product_id,
                    header_sensorid=self.device_id,
                    recs_SWITCH_STATE_value=True)
        self.send_message(payload)

    def turn_off(self):
        #TODO: header construction should be in MiHomeDevice as it is shared?
        payload = OpenThings.Message(SWITCH)
        payload.set(header_productid=self.product_id,
                    header_sensorid=self.device_id,
                    recs_SWITCH_STATE_value=False)
        self.send_message(payload)

    def set_switch(self, state):
        if state:
            self.turn_on()
        else:
            self.turn_off()

    #TODO: difference between 'is on and 'is requested on'
    #TODO: difference between 'is off' and 'is requested off'
    #TODO: switch state might be 'unknown' if not heard.
    #TODO: switch state might be 'turning_on' or 'turning_off' if send request and not heard response yet

    def is_on(self): # -> boolean
        """True, False, or None if unknown"""
        s = self.get_switch()
        if s == None: return None
        return s

    def is_off(self): # -> boolean
        """True, False, or None if unknown"""
        s = self.get_switch()
        if s == None: return None
        return not s

    def get_switch(self): # -> boolean
        """Last stored state of the switch, might be None if unknown"""
        return self.readings.switch

    def get_voltage(self): # -> voltage:float
        """Last stored state of voltage reading, None if unknown"""
        if self.readings.voltage == None:
            raise RuntimeError("No voltage reading received yet")
        return self.readings.voltage

    def get_frequency(self): # -> frequency:float
        """Last stored state of frequency reading, None if unknown"""
        if self.readings.frequency == None:
            raise RuntimeError("No frequency reading received yet")
        return self.readings.frequency

    def get_apparent_power(self): # ->power:float
        """Last stored state of apparent power reading, None if unknown"""
        if self.readings.apparent_power == None:
            raise RuntimeError("No apparent power reading received yet")
        return self.readings.apparent_power

    def get_reactive_power(self): # -> power:float
        """Last stored state of reactive power reading, None if unknown"""
        if self.readings.reactive_power == None:
            raise RuntimeError("No reactive power reading received yet")
        return self.readings.reactive_power

    def get_real_power(self): #-> power:float
        """Last stored state of real power reading, None if unknown"""
        if self.readings.real_power == None:
            raise RuntimeError("No real power reading received yet")
        return self.readings.real_power


#------------------------------------------------------------------------------

class MIHO006(MiHomeDevice):
    """An Energenie MiHome Home Monitor"""
    def __init__(self, device_id, air_interface=None):
        MiHomeDevice.__init__(self, device_id, air_interface)
        self.product_id = PRODUCTID_MIHO006
        class Readings():
            battery_voltage = None
            current         = None
            apparent_power  = None
        self.readings = Readings()
        self.capabilities.send = True

    def __repr__(self):
        return "MIHO006(%s)" % str(hex(self.device_id))

    def handle_message(self, payload):
        for rec in payload["recs"]:
            paramid = rec["paramid"]
            #TODO: consider making this table driven and allowing our base class to fill our readings in for us
            #TODO: consider using @OpenThings.parameter as a decorator to the receive function
            #it will then register a handler for that message for itself as a handler
            #we still need Readings() defined too as a cache. The decorator could add
            #an entry into the cache too for us perhaps?
            if "value" in rec:
                value = rec["value"]
                if paramid == OpenThings.PARAM_VOLTAGE:
                    self.readings.battery_voltage = value
                elif paramid == OpenThings.PARAM_CURRENT:
                    self.readings.current = value
                elif paramid == OpenThings.PARAM_APPARENT_POWER:
                    self.readings.apparent_power = value
                else:
                    try:
                        param_name = OpenThings.param_info[paramid]['n'] # name
                    except:
                        param_name = "UNKNOWN_%s" % str(hex(paramid))
                    print("unwanted paramid: %s" % param_name)
        pass

    def get_battery_voltage(self): # -> voltage:float
        return self.readings.battery_voltage

    def get_current(self): # -> current:float
        return self.readings.current

    def get_apparent_power(self): # -> power:float
        return self.readings.apparent_power



#------------------------------------------------------------------------------

class MIHO013(MiHomeDevice):
    """An Energenie MiHome eTRV Radiator Valve"""
    def __init__(self, device_id, air_interface=None):
        MiHomeDevice.__init__(self, device_id, air_interface)
        self.product_id = PRODUCTID_MIHO013
        class Readings():
            battery_voltage      = None
            ambient_temperature  = None
            pipe_temperature     = None
            setpoint_temperature = None
            valve_position       = None
            diagnostic_flags     = None
        self.readings = Readings()
        self.radio_config.inner_times = 4
        self.capabilities.send = True
        self.capabilities.receive = True
        self.send_queue = []
        self.lastVoltageReading = None
        self.lastDiagnosticsReading = None
        self.voltageReadingPeriod = 3600
        self.diagnosticsReadingPeriod = 3600

    def handle_message(self, payload):
        # check if it's time to refresh readings
        now=time.time()
        if self.voltageReadingPeriod != None and ( self.lastVoltageReading == None or now-self.lastVoltageReading>self.voltageReadingPeriod):
            self.queue_message(OpenThings.Message(MIHO013_BATTERY_LEVEL))
            self.lastVoltageReading = now
        
        if self.diagnosticsReadingPeriod != None and ( self.lastDiagnosticsReading == None or now-self.lastDiagnosticsReading>self.diagnosticsReadingPeriod):
            self.queue_message(OpenThings.Message(MIHO013_DIAGNOSTICS))
            self.lastDiagnosticsReading = now
        
        # send a message whilst receive window is open
        if len(self.send_queue)>0:
        	message=self.send_queue.pop(0)
        	self.send_message(message);
        	#print ("MIHO013 send %s (%s)" % (self.device_id, len(self.send_queue)))
        
        #extract data from message
        for rec in payload["recs"]:
            paramid = rec["paramid"]
            if "value" in rec:
                value = rec["value"]
                #print("MIHO013 new data %s %s %s" % (self.device_id, OpenThings.paramid_to_paramname(paramid), value))
                if paramid == OpenThings.PARAM_TEMPERATURE:
                    self.readings.ambient_temperature = value
                if paramid == OpenThings.PARAM_VOLTAGE:
                    self.readings.battery_voltage = value
                if paramid == OpenThings.PARAM_DIAGNOSTICS:
                    self.readings.diagnostic_flags = value
        
        
    def queue_message(self, message):
        message.set(
            header_productid=self.product_id,
            header_sensorid=self.device_id,
            header_encryptPIP=int(random.randrange(0xFFFF))
            )
        self.send_queue.append(copy.copy(message))

    def get_battery_voltage(self): # ->voltage:float
        return self.readings.battery_voltage

    def get_ambient_temperature(self): # -> temperature:float
        return self.readings.ambient_temperature

    def get_diagnostics(self):
        return self.readings.diagnostic_flags

    def get_setpoint_temperature(self): #-> temperature:float
        return self.readings.setpoint_temperature

    def set_setpoint_temperature(self, temperature):
        self.readings.setpoint_temperature = temperature;
        payload = OpenThings.Message(MIHO013_SET_TEMPERATURE).copyof()
        if temperature<0:
            temperature=0
        if temperature>30:
            temperature=30
        payload.set(recs_TEMPERATURE_value=int(temperature*256))
        self.queue_message(payload)

    def set_valve_position(self, position):
		payload = OpenThings.Message(MIHO013_SET_VALVE_POSITION).copyof()
		payload.set(recs_VALVE_POSITION_value=position)
		self.queue_message(payload)   

    def set_identify(self):
        self.queue_message(OpenThings.Message(MIHO013_IDENTIFY).copyof())

    def turn_on(self): 
        self.set_valve_position(0)

    def turn_off(self): 
        self.set_valve_position(1)
        
    def enable_thermostat(self): 
        self.set_valve_position(2)


#------------------------------------------------------------------------------

class MIHO032(MiHomeDevice):
    """An Energenie Motion Sensor"""
    def __init__(self, device_id, air_interface=None):
        MiHomeDevice.__init__(self, device_id, air_interface)
        self.product_id = PRODUCTID_MIHO032
        class Readings():
            switch_state = None
            battery_alarm = None
        self.readings = Readings()
        self.capabilities.send = True
        self.callback=None

    def __repr__(self):
        return "MIHO032(%s)" % str(hex(self.device_id))
        
    def setCallback(self,callback):
        self.callback=callback

    def handle_message(self, payload):
        ##print("MIHO032 new data %s %s" % (self.device_id, payload))
        ##sys.stdout.flush()
        for rec in payload["recs"]:
            paramid = rec["paramid"]
            #TODO: consider making this table driven and allowing our base class to fill our readings in for us
            #TODO: consider using @OpenThings.parameter as a decorator to the receive function
            #it will then register a handler for that message for itself as a handler
            #we still need Readings() defined too as a cache. The decorator could add
            #an entry into the cache too for us perhaps?
            if "value" in rec:
                value = rec["value"]
                if paramid == OpenThings.PARAM_MOTION_DETECTOR:
                    state = ((value == True) or (value != 0));
                    if self.readings.switch_state!= state:
                       self.readings.switch_state = state
                       #print("MIHO032 new data %s %s" % (self.device_id, payload))
                       if self.callback!=None:
                           self.callback(self,state)
                elif paramid == OpenThings.PARAM_ALARM:
                    if value == 0x42: # battery alarming
                        self.readings.battery_alarm = True
                    elif value == 0x62: # battery not alarming
                        self.readings.battery_alarm = False
                else:
                    try:
                        param_name = OpenThings.param_info[paramid]['n'] # name
                    except:
                        param_name = "UNKNOWN_%s" % str(hex(paramid))
                    print("unwanted paramid: %s" % param_name)

    def get_switch_state(self): # -> switch:bool
        return self.readings.switch_state

    def get_battery_alarm(self): # -> alarm:bool
        return self.readings.battery_alarm

#------------------------------------------------------------------------------

class MIHO033(MiHomeDevice):
    """An Energenie Open Sensor"""
    def __init__(self, device_id, air_interface=None):
        MiHomeDevice.__init__(self, device_id, air_interface)
        self.product_id = PRODUCTID_MIHO033
        class Readings():
            switch_state = None
        self.readings = Readings()
        self.capabilities.send = True

    def __repr__(self):
        return "MIHO033(%s)" % str(hex(self.device_id))

    def handle_message(self, payload):
        ##print("MIHO033 new data %s %s" % (self.device_id, payload))
        for rec in payload["recs"]:
            paramid = rec["paramid"]
            #TODO: consider making this table driven and allowing our base class to fill our readings in for us
            #TODO: consider using @OpenThings.parameter as a decorator to the receive function
            #it will then register a handler for that message for itself as a handler
            #we still need Readings() defined too as a cache. The decorator could add
            #an entry into the cache too for us perhaps?
            if "value" in rec:
                value = rec["value"]
                if paramid == OpenThings.PARAM_DOOR_SENSOR:
                    self.readings.switch_state = ((value == True) or (value != 0))
                else:
                    try:
                        param_name = OpenThings.param_info[paramid]['n'] # name
                    except:
                        param_name = "UNKNOWN_%s" % str(hex(paramid))
                    print("unwanted paramid: %s" % param_name)

    def get_switch_state(self): # -> switch:bool
        return self.readings.switch_state


#----- DEVICE FACTORY ---------------------------------------------------------

# This is a singleton, but might not be in the future.
# i.e. we might have device factories for lots of different devices.
# and a DeviceFactory could auto configure it's set of devices
# with a specific air_interface for us.
# i.e. this might be the EnergenieDeviceFactory, there might be others
# for other product ranges like wirefree doorbells


class DeviceFactory():
    """A place to come to, to get instances of device classes"""
    # If you know the name of the device, use this table
    device_from_name = {
        # official name            friendly name
        "ENER002":     ENER002,    "GreenButton":       ENER002, # OOK(rx)
        "MIHO002":     MIHO002,    "Controller":        MIHO002, # OOK(rx)
        "MIHO004":     MIHO004,    "Monitor":           MIHO004, # FSK(rx)
        "MIHO005":     MIHO005,    "AdaptorPlus":       MIHO005, # FSK(tx,rx)
        "MIHO006":     MIHO006,    "HomeMonitor":       MIHO006, # FSK(tx)
        "MIHO008":     MIHO008,    "MiHomeLightWhite":  MIHO008, # OOK(rx)
        "MIHO013":     MIHO013,    "eTRV":              MIHO013, # FSK(tx,rx)
        "MIHO014":     MIHO014,    "3kWRelay":          MIHO014, # OOK(rx)
        "MIHO024":     MIHO024,    "MiHomeLightBlack":  MIHO024, # OOK(rx)
        "MIHO025":     MIHO025,    "MiHomeLightChrome": MIHO025, # OOK(rx)
        "MIHO026":     MIHO026,    "MiHomeLightSteel":  MIHO026, # OOK(rx)
        "MIHO032":     MIHO032,    "MotionSensor":      MIHO032, # FSK(tx)
        "MIHO033":     MIHO033,    "OpenSensor":        MIHO033, # FSK(tx)
    }

    #TODO: These are MiHome devices only, but might add in mfrid prefix too
    # If you know the mfrid, productid of the device, use this table
    device_from_id = {
        #ENER002 is anOOK
        #MIHO002 control only switch is an OOK
        PRODUCTID_MIHO004: MIHO004,
        PRODUCTID_MIHO005: MIHO005,
        PRODUCTID_MIHO006: MIHO006,
        #MIHO008 is an OOK
        PRODUCTID_MIHO013: MIHO013,
        #MIHO014 is an OOK
        #MIHO024 is an OOK
        #MIHO025 is an OOK
        #MIHO026 is an OOK
        PRODUCTID_MIHO032: MIHO032,
        PRODUCTID_MIHO033: MIHO033
    }

    default_air_interface = None

    @staticmethod
    def set_default_air_interface(air_interface):
        DeviceFactory.default_air_interface = air_interface

    @staticmethod
    def keys():
        return DeviceFactory.device_from_name.keys()

    @staticmethod
    def get_device_from_name(name, device_id=None, air_interface=None, **kwargs):
        """Get a device by name, construct a new instance"""
        # e.g. This is useful when creating device class instances from a human readable config
        if not name in DeviceFactory.device_from_name:
            raise ValueError("Unsupported device:%s" % name)

        c = DeviceFactory.device_from_name[name]
        if air_interface == None:
            air_interface = DeviceFactory.default_air_interface
        return c(device_id, air_interface, **kwargs)

    @staticmethod
    def get_device_from_id(id, device_id=None, air_interface=None):
        """Get a device by it's id, construct a new instance"""
        # e.g. This is useful when recreating device class instances from a persisted registry
        if not id in DeviceFactory.device_from_id:
            raise ValueError("Unsupported device id:%s" % id)

        c = DeviceFactory.device_from_id[id]
        if air_interface == None:
            air_interface = DeviceFactory.default_air_interface
        i = c(device_id, air_interface)
        print(i)
        return i


# END
