diff --git a/doc/devices_classes_branch.txt b/doc/devices_classes_branch.txt new file mode 100644 index 0000000..0bd7ec0 --- /dev/null +++ b/doc/devices_classes_branch.txt @@ -0,0 +1,238 @@ +DEVICE CLASSES + +A device class is a scheme where user devices plugged into Energenie +product, can be referred to as objects within a user application. + +It is a way of abstracting the on-air radio interface and Energenie +device specifics from a user application, such that the user can +code 'in the land of their devices'. + + +-------------------------------------------------------------------------------- +REQUIREMENTS + +YES 1. EXPRESSIVE: To be able to write expressive and compact applications, + that talk in the vocabulary of physical devices. + + YES a. All known Energenie devices to be modelled as classes inside a + device database, and the capabilities and operations on those devices + pre-written so they can be reused by a user application. + + YES b. An easy way for users to map energenie device intents to user + device intents (such as by wrapping custom object vocabulary around + the standard energenie device vocabulary) - e.g. room.heat() rather + than plug.on() + + e.g. first level device names such as my_radiator or my_plug, + or second level device names such as bedroom_radiator and kitchen_kettle + (things plugged into devices) + + This will hide the detail of how messages get encoded and transported, + and allows users to focus ore on the intents of the application, rather + than the implementation details. + + +HMM? 2. NAME REGISTRY: To be able to build a local registry of devices and their configurations, + and refer to devices by name inside the application. + + YES a. to be configurable by learning (e.g. listen for messages such as + a join message, and add the device to the registry) + + YES b. to be configurable by hand (e.g. hand entering the sensor id of + a known device into the registry) + + YES c. to automatically build variables for the user program from the + registry, so that users don't have to bother with lots of + wiring up code every time their app starts. + + YES d. this registry must be persistable, e.g. save and restore to a disk file, + so that on application startup, the device database is automatically loaded + and objects created. + + HMM? e. the registry can be queried, such as 'find me all devices that are of + type x' or 'find me all devices in location kitchen'. + + +HMM? 3. INTENTS: To be able to command and query devices in a way that represents + meaningful device-based intents (such as tv.on() and tv.get_power()) + + YES a. received data values to be cached for deferred query, such as get_power() + + POSSIBLE b. the last receipt time of data from a transmitting device to be known + + POSSIBLY c. the next expected receipt time of data from a transmitting device to be known + + POSSIBLY d. the last known state of a transmitting device to be known (e.g. switch state + both by commanded state and retrieved state) + + +YES 4. AGNOSTIC: To be able to refer to user devices in an Energenie device agnostic way. + + e.g. it doesn't matter if the TV is plugged into a green button device, + or a MiHome device. It is always tv.on() in the code. + + +YES 5. LEARN/DISCOVER: To be able to instigate and manage learn mode from within an app + + YES a. To send specific commands to green button devices so they can + learn the pattern + + YES b. To sniff for any messages from MiHome devices and capture them + for later analysis and turning into device objects + + YES c. To process MiHome join requests, and send MiHome join acks + + +YES 6. ABSTRACTED RADIO: To completely hide the user from the on-air radio interface + + YES a. choosing the correct radio frequency and modulation automatically + + YES b. choosing the correct physical layer configuration automatically, + such as message repeats for certain devices + + +Not as part of this work, but this should at least be enabled +by the design + +HMM? 7. PERFORMING: To be able to build a well performing system + with very few message collisions and message losses + + POSSIBLE a. by dynamically learning report patterns of MiHome devices + + POSSIBLE b. by intelligently deferring and schedulling transmit messages + to avoid transmit slots of reporting devices + + POSSIBLE c. to query device characteristics such as modulation scheme and msg repeats. + also to estimate the transmit time of a particular message to help + with message scheduling. + + +-------------------------------------------------------------------------------- +DESIGN Devices.py + +MOSTLY DONE, + +remaining items to investigate: + + commanded state? (did we ask it to be on, when did we ask?) + reported state? (did it tell us it is on, when did we learn it?) + + overall device state + have we seen this device this run? + when did we last hear from it? + when did we last talk to it? + when do we expect to next hear from it? + + yet unmodelled devices still to be usable to some degree + for MiHome devices, a proxy class generated dynamically based on received message parameters. + e.g. if it reports a TEMPERATURE field, then there should be an automatic get_temperature() method + generated. + + possibly add callbacks such as when_turned_on() when_turned_off() etc. + +(do we need to know what our last sent request is, vs last known reported state? +e.g. if we have sent a request but not heard a response yet, this means we think we asked +it to turn on, but we don't yet know if it has done that. Some devices can't report +back, but some can, so it would be nice to have a four stage state machine for on/off) + +(note, would be good to be able to persist the last message received on disk, +so that when code restarts, it knows the last send/receive time that was last processed. +i.e. a resumable state machine persisted to disk) + +(note, a message scheduler if inserted in the middle, would do callbacks to say +that the request has been processed, so timestamps can be updated. Also same scheduler +could handle retries perhaps, if the device is tx and rx, then when you send a switch +change, it would normally report back that the switch had changed, so if you don't +get it, or if it is in the wrong state, could retry a send again until it changes) + +(note, inner variables might have two versions for some devices, the requested +value and the confirmed value. If they are different, it means might still be +waiting for a reply, so can't guarantee the command was received yet) + + +-------------------------------------------------------------------------------- +DESIGN Registry.py + +DONE + + +-------------------------------------------------------------------------------- +DESIGN - air_interface adaptors for FSK and OOK + +DONE + + +-------------------------------------------------------------------------------- +DESIGN NOTES - registry data store + +REQUIREMENT: I want a simple persistent kvp database with the following features: + +YES: 1. A file format that is portable across all platforms +YES: 2. A file format that is human readable and easily editable +YES: 3. A simple read and write key/value abstraction in python with a full CRUD lifecycle +YES: 4. Doesn't have to be hugely efficient or store very large data sets +YES: MIT licence +YES: 6. A single python file +TODO: 7. Works out of the box with no changes on Python 2 and Python 3 + +Additionally, it might: + +YES: 8. A option to add multi process locking later if required, but not + included by default + + so that it could be used as a simple central database for multiple + programs sharing the same data set. + +POSSIBLE: 9. understand read only and read/write intents better + + when using configuration data and last known values, it is useful + to keep them in the same single file, so it is easy to copy + to other machines. Some data is naturally 'write once' and + very configuration based. Some data is naturally 'write often'. + It might be nice if these two types of data could appear in the same + file, but the locking/performance and resilience issues be handled + differently for the two classes of data - e.g. perhaps having + two connections to the same database file, one in read only mode + for config records, and one in read/write mode for last use data. + There might also be different namespace prefixes in the file + so that the key sets are separate, or there may be a way to + link them so that when you read a record you get both the static + config data and the fast changing last use data as a single + record. But this then implies when you do an update, you + probably want to update part of a record rather than the + whole record. + + +Note: callbacks on when_updated() might be required + + +-------------------------------------------------------------------------------- +PRESENT STATUS + +Router written and integrated in energenie.loop() +Discovery behaviours written and tested ok +monitor_mihome works with a synthetic join and toggles switches +KVS implementation completed and all tests pass +Registry tests all complete +receive sequence counter tested +setup_tool implemented and tested in simulation +fixed testers to use new registry and device classes +auto create example written using example registry +all test harnesses now run as a group fine +works on hardware +works on python3 (after a fashion!) + +-------------------------------------------------------------------------------- +PLAN UP TO: MERGE BACK TO MASTER + + +----- RELEASE TESTING AND RELEASE + +* update the test instructions and re-test everything before merge + +* Any outstanding items on the list above, feed back into issues on the TODO list, +so that they get dealt with in a later pass + +* merge to master after test + +END diff --git a/src/cleanup.py b/src/cleanup.py deleted file mode 100644 index 2c430e6..0000000 --- a/src/cleanup.py +++ /dev/null @@ -1,18 +0,0 @@ -# cleanup.py 05/04/2016 D.J.Whale -# -# Put all used GPIO pins into an input state -# Useful to recover from a crash - -import RPi.GPIO as GPIO -GPIO.setmode(GPIO.BCM) - -GPIO.setup(27, GPIO.IN) # Green LED -GPIO.setup(22, GPIO.IN) # Red LED -GPIO.setup(7, GPIO.IN) # CS -GPIO.setup(8, GPIO.IN) # CS -GPIO.setup(11, GPIO.IN) # SCLK -GPIO.setup(10, GPIO.IN) # MOSI -GPIO.setup(9, GPIO.IN) # MISO -GPIO.setup(25, GPIO.IN) # RESET - -GPIO.cleanup() diff --git a/src/cleanup_GPIO.py b/src/cleanup_GPIO.py new file mode 100644 index 0000000..3f0064d --- /dev/null +++ b/src/cleanup_GPIO.py @@ -0,0 +1,17 @@ +# cleanup.py 05/04/2016 D.J.Whale +# +# Put all used Raspberry Pi GPIO pins into an input state. Useful to recover from a crash + +import RPi.GPIO as GPIO +GPIO.setmode(GPIO.BCM) + +GPIO.setup(27, GPIO.IN) # Green LED +GPIO.setup(22, GPIO.IN) # Red LED +GPIO.setup(7, GPIO.IN) # CS +GPIO.setup(8, GPIO.IN) # CS +GPIO.setup(11, GPIO.IN) # SCLK +GPIO.setup(10, GPIO.IN) # MOSI +GPIO.setup(9, GPIO.IN) # MISO +GPIO.setup(25, GPIO.IN) # RESET + +GPIO.cleanup() diff --git a/src/combined.py b/src/combined.py deleted file mode 100644 index 5467c22..0000000 --- a/src/combined.py +++ /dev/null @@ -1,71 +0,0 @@ -# combined.py 15/05/2016 D.J.Whale -# -# A simple demo of combining both FSK (MiHome) and OOK (green button legacy) -# -# NOTE: This is only a test harness. -# If you really want a nice way to control these devices, wait for the 'device classes' -# issues to be implemented and tested on top of the raw radio interface, as these -# will be much nicer to use. - -import time -from energenie import Messages, OpenThings, radio, encoder, Devices - -# build FSK messages for MiHome purple - -OpenThings.init(Devices.CRYPT_PID) - -PURPLE_ID = 0x68B # captured from a real device using Monitor.py -m = OpenThings.alterMessage( - Messages.SWITCH, - header_sensorid=PURPLE_ID, - recs_0_value=1) -purple_on = OpenThings.encode(m) - -m = OpenThings.alterMessage( - Messages.SWITCH, - header_sensorid=PURPLE_ID, - recs_0_value=0) -purple_off = OpenThings.encode(m) - -# build OOK messages for legacy green button - -GREEN_ON = encoder.build_switch_msg(True, device_address=1) -GREEN_OFF = encoder.build_switch_msg(False, device_address=1) - - -def switch_loop(): - print("Turning green ON") - radio.modulation(ook=True) - radio.transmit(GREEN_ON) - time.sleep(0.5) - - print("Turning purple ON") - radio.modulation(fsk=True) - radio.transmit(purple_on, inner_times=2) - time.sleep(2) - - print("Turning green OFF") - radio.modulation(ook=True) - radio.transmit(GREEN_OFF) - time.sleep(0.5) - - print("Turning purple OFF") - radio.modulation(fsk=True) - radio.transmit(purple_off, inner_times=2) - time.sleep(2) - - -if __name__ == "__main__": - - print("starting combined switch tester") - print("radio init") - radio.init() - - try: - while True: - switch_loop() - - finally: - radio.finished() - -# END diff --git a/src/control_any_auto.py b/src/control_any_auto.py new file mode 100644 index 0000000..f334d48 --- /dev/null +++ b/src/control_any_auto.py @@ -0,0 +1,50 @@ +# control_any_auto.py 29/05/2016 D.J.Whale +# +# Demonstrates the variable auto-create. +# +# Variables are auto created into a given context, entirely from the registry. +# You should seed the registry first with setup_tool.py and give the devices +# the correct names, before this will work. + +import time +import energenie + +APP_DELAY = 1 + +def auto_loop(): + + # Use the auto-generated variables 'fan' and 'tv'. + # These can be any device that has a switch. + # They must be defined with these names in the registry for this code to work. + print("Turning ON") + fan.turn_on() + tv.turn_on() + time.sleep(APP_DELAY) + + print("Turning OFF") + fan.turn_off() + tv.turn_off() + time.sleep(APP_DELAY) + + + +if __name__ == "__main__": + + print("Starting auto example") + + energenie.init() + + # Load all devices into variables auto created in the global scope + # You can pass any context here, such as a class to contain your devices + import sys + me_global = sys.modules[__name__] + energenie.registry.load_into(me_global) + + try: + while True: + auto_loop() + + finally: + energenie.finished() + +# END diff --git a/src/control_any_noreg.py b/src/control_any_noreg.py new file mode 100644 index 0000000..75acafb --- /dev/null +++ b/src/control_any_noreg.py @@ -0,0 +1,55 @@ +# control_any_noreg.py 17/03/2016 D.J.Whale +# +# Control up to 4 legacy green-button sockets (or MiHome control-only sockets) +# Shows how to address sockets directly without using the registry. + +import time +import energenie + +APP_DELAY = 1 + +# Devices that use the standard Energenie house code +all_sockets = energenie.Devices.ENER002(0) +socket1 = energenie.Devices.ENER002(1) +socket2 = energenie.Devices.ENER002(2) +socket3 = energenie.Devices.ENER002(3) +socket4 = energenie.Devices.ENER002(4) + +# A device that uses a custom house code (e.g. learnt from a hand controller) +socket5 = energenie.Devices.ENER002((0x1234, 1)) + +# A MiHome device that we know the address of from a previous capture +socket6 = energenie.Devices.MIHO005(0x68b) + +sockets = [all_sockets, socket1, socket2, socket3, socket4, socket5, socket6] + + +def legacy_socket_loop(): + """Turn all sockets on or off every few seconds""" + + while True: + for socket_no in range(len(sockets)): + # socket_no 0 is ALL, then 1=1, 2=2, 3=3, 4=4 + # ON + print("socket %d ON" % socket_no) + sockets[socket_no].turn_on() + time.sleep(APP_DELAY) + + # OFF + print("socket %d OFF" % socket_no) + sockets[socket_no].turn_off() + time.sleep(APP_DELAY) + + +if __name__ == "__main__": + print("starting socket tester (no registry)") + + energenie.init() + + try: + legacy_socket_loop() + finally: + energenie.finished() + +# END + diff --git a/src/control_any_reg.py b/src/control_any_reg.py new file mode 100644 index 0000000..e8b0957 --- /dev/null +++ b/src/control_any_reg.py @@ -0,0 +1,49 @@ +# control_any.py 17/03/2016 D.J.Whale +# +# Control Energenie MiHome Adaptor or AdaptorPlus sockets +# and also ENER002 legacy green button sockets. + +# Shows how to use the registry to create devices. +# You should first run setup_tool.py and join some sockets + +import time +import energenie + +APP_DELAY = 2 # number of seconds to toggle the socket switches + + +#----- TEST APPLICATION ------------------------------------------------------- + +def socket_toggle_loop(): + """Toggle the switch on all devices in the directory""" + + global socket_state + + print("Setting socket switches to %s" % str(socket_state)) + + for device in energenie.registry.devices(): + # Only try to toggle the switch for devices that actually have a switch + + if device.has_switch(): + print(" socket id %s" % device) + device.set_switch(socket_state) + + socket_state = not socket_state + time.sleep(APP_DELAY) + + +if __name__ == "__main__": + + print("starting socket tester (from registry)") + energenie.init() + + socket_state = False + + try: + while True: + socket_toggle_loop() + + finally: + energenie.finished() + +# END diff --git a/src/discover_mihome.py b/src/discover_mihome.py new file mode 100644 index 0000000..124c283 --- /dev/null +++ b/src/discover_mihome.py @@ -0,0 +1,59 @@ +# discover_mihome.py 24/05/2016 D.J.Whale +# +# You can discover devices and store them in the registry with setup_tool.py +# However, this is an example of how to do your own discovery using +# one of the built in discovery design patterns. + +import energenie + +# You could also use the standard energenie.ask callback instead if you want +# as that does exactly the same thing + +def ask_fn(address, message): + MSG = "Do you want to register to device: %s? " % str(address) + try: + if message != None: + print(message) + y = raw_input(MSG) + + except NameError: + y = input(MSG) + + if y == "": return True + y = y.upper() + if y in ['Y', 'YES']: return True + return False + + +def discover_mihome(): + # Select your discovery behaviour from one of these: + ##energenie.discovery_auto() + energenie.discovery_ask(ask_fn) + ##energenie.discovery_autojoin() + ##energenie.discovery_askjoin(ask_fn) + + # Run the receive loop permanently, so that receive messages are processed + try: + print("Discovery running, Ctrl-C to stop") + while True: + energenie.loop() + + except KeyboardInterrupt: + pass # user abort + + +if __name__ == "__main__": + + print("Starting discovery example") + + energenie.init() + + try: + discover_mihome() + + finally: + energenie.finished() + + +# END + diff --git a/src/energenie/Devices.py b/src/energenie/Devices.py index f2275bb..78ba11d 100644 --- a/src/energenie/Devices.py +++ b/src/energenie/Devices.py @@ -3,55 +3,67 @@ # Information about specific Energenie devices # This table is mostly reverse-engineered from various websites and web catalogues. -MFRID = 0x04 +##from lifecycle import * +try: + # Python 2 + import OnAir + import OpenThings +except ImportError: + # Python 3 + from . import OnAir + from . import OpenThings -# Deprecated, these are old device names, do not use. -#PRODUCTID_C1_MONITOR = 0x01 # MIHO004 Monitor -#PRODUCTID_R1_MONITOR_AND_CONTROL = 0x02 # MIHO005 Adaptor Plus +# This level of indirection allows easy mocking for testing +ook_interface = OnAir.TwoBitAirInterface() +fsk_interface = OnAir.OpenThingsAirInterface() -#PRODUCTID_MIHO001 = # Home Hub -#PRODUCTID_MIHO002 = # Control only (Uses Legacy OOK protocol) -#PRODUCTID_MIHO003 = 0x0? # Hand Controller + +MFRID_ENERGENIE = 0x04 +MFRID = MFRID_ENERGENIE + +##PRODUCTID_MIHO001 = # Home Hub +##PRODUCTID_MIHO002 = # Control only (Uses Legacy OOK protocol) +##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? # Single light switch -#PRODUCTID_MIHO009 not used -#PRODUCTID_MIHO010 not used -#PRODUCTID_MIHO011 not used -#PRODUCTID_MIHO012 not used +##PRODUCTID_MIHO007 = 0x0? # Double Wall Socket White +##PRODUCTID_MIHO008 = 0x0? # Single light switch +##PRODUCTID_MIHO009 not used +##PRODUCTID_MIHO010 not used +##PRODUCTID_MIHO011 not used +##PRODUCTID_MIHO012 not used PRODUCTID_MIHO013 = 0x03 # eTRV -#PRODUCTID_MIHO014 = 0x0? # 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? # Style Light Nickel -#PRODUCTID_MIHO025 = 0x0? # Style Light Chrome -#PRODUCTID_MIHO026 = 0x0? # 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 not used -#PRODUCTID_MIHO033 not used -#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 +##PRODUCTID_MIHO014 = 0x0? # 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? # Style Light Nickel +##PRODUCTID_MIHO025 = 0x0? # Style Light Chrome +##PRODUCTID_MIHO026 = 0x0? # 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 not used +##PRODUCTID_MIHO033 not used +##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 @@ -60,33 +72,753 @@ # This makes simple discovery possible. BROADCAST_ID = 0xFFFFFF # Energenie broadcast -#TODO: put additional products in here from the Energenie directory -#TODO: make this table based -def getDescription(mfrid, productid): - if mfrid == MFRID: - mfr = "Energenie" - if productid == PRODUCTID_MIHO004: - product = "MIHO004 MONITOR" - elif productid == PRODUCTID_MIHO005: - product = "MIHO005 ADAPTOR PLUS" - elif productid == PRODUCTID_MIHO006: - product = "MIHO006 HOUSE MONITOR" - elif productid == PRODUCTID_MIHO013: - product = "MIHO013 ETRV" +#----- 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 + } + ] +} + +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": 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 + } + ] +} + +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 Config(): pass + self.config = Config() + class Capabilities(): pass + self.capabilities = Capabilities() + self.updated_cb = None + self.rxseq = 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: - product = "UNKNOWN_%s" % str(hex(productid)) - else: - mfr = "UNKNOWN_%s" % str(hex(mfrid)) - product = "UNKNOWN_%s" % str(hex(productid)) - - return "Manufacturer:%s Product:%s" % (mfr, product) + raise ValueError("device_id unsupported type or format, got: %s %s" % (type(device_id), str(device_id))) -def hasSwitch(mfrid, productid): - if mfrid != MFRID: return False - if productid == PRODUCTID_MIHO005: return True - return False + 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.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) + + 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 "Device(%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 might now just be implied by the ook_interface adaptor + self.config.frequency = 433.92 + self.config.modulation = "OOK" + self.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): + #TODO: interface with air_interface + # Encode the payload two bits per byte as per OOK spec + #TODO: should we just pass a payload (as a pydict or tuple) to the air_interface adaptor + #and let it encode it, to be consistent with the FSK MiHome devices? + #payload could be a 3-tuple of (house_address, device_address, state) + ##bytes = TwoBit.build_switch_msg(payload, house_address=self.device_id[0], device_address=self.device_id[1]) + + if self.air_interface != None: + #TODO: might want to send the config, either as a send parameter, + #or by calling air_interface.configure() first? + #i.e. radio.modulation(MODULATION_OOK) + self.air_interface.send(payload) #TODO: or (ha, da, s) + 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) + self.config.frequency = 433.92 + self.config.modulation = "FSK" + self.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""" + 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) + + ##def handle_message(self, payload): + #override for any specific handling + + def send_message(self, payload): + #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) + 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 ENER002(LegacyDevice): + """A green-button switch""" + def __init__(self, device_id, air_interface=None): + LegacyDevice.__init__(self, device_id, air_interface) + self.config.tx_repeats = 8 + self.capabilities.switch = True + self.capabilities.receive = True + + def __repr__(self): + return "ENER002(%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 MIHO004(MiHomeDevice): + """Monitor only Adaptor""" + pass #TODO + + +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.config.tx_repeats = 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 + self.readings = Readings() + self.capabilities.send = True + + def get_battery_voltage(self): # -> voltage:float + return self.readings.battery_voltage + + def get_current(self): # -> current:float + return self.readings.current + + +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 + self.readings = Readings() + self.config.tx_repeats = 10 + self.capabilities.send = True + self.capabilities.receive = True + + 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_pipe_temperature(self): # -> temperature:float + return self.readings.pipe_temperature + + def get_setpoint_temperature(self): #-> temperature:float + return self.readings.setpoint_temperature + + def set_setpoint_temperature(self, temperature): + self.send_message("set setpoint temp") #TODO: command + + def get_valve_position(self): # -> position:int? + pass #TODO: is this possible? + + def set_valve_position(self, position): + pass #TODO: command, is this possible? + self.send_message("set valve pos") #TODO + + #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 turn_on(self): # command + pass #TODO: command i.e. valve position? + self.send_message("turn on") #TODO + + def turn_off(self): # command + pass #TODO: command i.e. valve position? + self.send_message("turn off") #TODO + + def is_on(self): # query last known reported state (unknown if changing?) + pass #TODO: i.e valve is not completely closed? + + def is_off(self): # query last known reported state (unknown if changing?) + pass #TODO: i.e. valve is completely closed? + + +#----- 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, + "MIHO005": MIHO005, "AdaptorPlus": MIHO005, + "MIHO006": MIHO006, "HomeMonitor": MIHO006, + "MIHO013": MIHO013, "eTRV": MIHO013, + } + + #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 = { + PRODUCTID_MIHO004: MIHO004, + PRODUCTID_MIHO005: MIHO005, + PRODUCTID_MIHO006: MIHO006, + PRODUCTID_MIHO013: MIHO013 + #ENER product range does not have deviceid, as it does not transmit + } + + 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 + diff --git a/src/energenie/Devices_test.py b/src/energenie/Devices_test.py new file mode 100644 index 0000000..70d5552 --- /dev/null +++ b/src/energenie/Devices_test.py @@ -0,0 +1,63 @@ +# Devices_test.py 21/05/2016 D.J.Whale +# +# Test harness for Devices module + +import unittest +from lifecycle import * + +try: + # Python 2 + import Devices + import OpenThings + import radio + +except ImportError: + # Python 3 + from . import Devices + from . import OpenThings + from . import radio + +class TestDevices(unittest.TestCase): + + @test_1 + def test_without_registry(self): + """A simple on/off test with some devices from the device factory""" + tv = Devices.DeviceFactory.get_device_from_name("GreenButton", device_id=(0xC8C8C, 1)) + fan = Devices.DeviceFactory.get_device_from_name("AdaptorPlus", device_id=0x68b) + xbox = Devices.DeviceFactory.get_device_from_id(Devices.PRODUCTID_MIHO005, device_id=10) + + print("ON") + tv.turn_on() + fan.turn_off() + xbox.turn_off() + + print("OFF") + tv.turn_off() + fan.turn_on() + xbox.turn_on() + + @test_1 + def test_rx_seq(self): + """Test that the rx sequence increments on each received message""" + fan = Devices.DeviceFactory.get_device_from_name("AdaptorPlus", device_id=0x68b) + + msg = OpenThings.Message(Devices.MIHO005_REPORT) + print(fan.get_receive_count()) + + fan.incoming_message(msg) + print(fan.get_receive_count()) + + +def init(): + """Start the Energenie system running""" + radio.DEBUG = True + radio.init() + OpenThings.init(Devices.CRYPT_PID) + + +if __name__ == "__main__": + init() + unittest.main() + +# END + diff --git a/src/energenie/KVS.py b/src/energenie/KVS.py new file mode 100644 index 0000000..17a55ed --- /dev/null +++ b/src/energenie/KVS.py @@ -0,0 +1,189 @@ +# KVS.py 27/05/2016 D.J.Whale +# +# A generic key value store + +##from lifecycle import * + +class NotPersistableError(Exception): + pass + +class KVS(): + """A persistent key value store""" + def __init__(self, filename=None): + self.filename = filename + self.store = {} + + def load(self, filename=None, create_fn=None): + """Load the whole file into an in-memory cache""" + + # use new filename if provided, else use existing filename + if filename == None: + filename = self.filename + if filename == None: + raise ValueError("No filename specified") + + #TODO: The 'callback' is called to process each record as it is read in?? + # for the Registry, this is a way that it can create the class and also add receive routing + + is_cmd = True + command = None + key = None + obj = None + + ##print("load from %s" % filename) + with open(filename) as f: + while True: + line = f.readline() + if line == "": # EOF + if command != None: + self.process(command, key, obj, create_fn) + break # END + else: + line = line.strip() # remove nl + if is_cmd: + if len(line) == 0: # blank + pass # ignore extra blank lines + else: # not blank + # split line, first word is command, second word is the key + command, key = line.split(" ", 1) + obj = {} + is_cmd = False # now in data mode + + else: # in data mode + if len(line) > 0: # not blank + ##print("parsing %s" % line) + k,v = line.split("=", 1) + obj[k] = v + else: # is blank + self.process(command, key, obj, create_fn) + command = None + is_cmd = True + + self.filename = filename # remember filename if it was provided + + def process(self, command, key, obj, create_fn): + """Process the temporary object""" + m = getattr(self, command) + #If command is not found? get AttributeError - that's fine + m(key, obj, create_fn) + + def ADD(self, key, obj, create_fn=None): + """Add a new item to the kvs""" + # The ADD command processes the next type= parameter as the class name in context + # all other parameters are read as strings and passed to class constructor as kwargs + + if create_fn != None: + ##print("calling create_fn to turn into a class instance") + type = obj["type"] + del obj["type"] # don't pass to constructor + obj = create_fn(type, **obj) + # If this fails, then this is an error, so just let it bubble out + else: + pass ##print("no create_fn configured, just storing kvp") + + ##print("object: %s" % obj) + # store kvp or class instance appropriately + self.store[key] = obj + + def IGN(self, key, obj=None, create_fn=None): + """Ignore the whole record""" + # The IGN command is the same length as ADD, allowing a seek/write to change any + # command into IGN without changing the file size, effectively patching the file + # so that the record is deleted. + pass # There is nothing to do with this command + + def DEL(self, key, obj=None, create_fn=None): + """Delete the key from the store""" + # The DEL command deletes the rec from the store. + # This is useful to build temporary objects and delete them later. + # There is no need to write this to the file copy, we're processing the file + del self.store[key] + + def __getitem__(self, key): + return self.store[key] + + def __setitem__(self, key, value): + if key in self.store: + self.remove(key) # patches it to an IGN record + + self.store[key] = value + try: + obj = value.get_config() # will fail with AttributeError if this method does not exist + except AttributeError: + raise NotPersistableError() + self.append(key, obj) + + def __delitem__(self, key): + del self.store[key] + self.remove(key) + + def __len__(self): + return len(self.store) + + def keys(self): + return self.store.keys() + + def append(self, key, values): + """Append a new record to the persistent file""" + ##print("append:%s %s" % (key, values)) + + if self.filename != None: + with open(self.filename, 'a') as f: + f.write("\nADD %s\n" % key) + for k in values: + v = values[k] + f.write("%s=%s\n" % (k, v)) + f.write("\n") + + def remove(self, key): + """Remove reference to this key in the file""" + if self.filename == None: + return # No file, nothing to do + + with open(self.filename, 'r+') as f: # read and write mode + while True: + start = f.tell() # start of current line + line = f.readline() + end = f.tell() # position just beyond end of line + if line == "": break # EOF + # don't worry about cmd/data, space in search string disambiguates + if line.startswith("ADD "): # space after is critical to simplify parser + line = line.strip() # remove nl + cmd, this_key = line.split(" ", 1) + if this_key == key: + ##print("found: %s %s" % (cmd, this_key)) + f.seek(start) # back to start of line + f.write('IGN') # patch it to be an ignore record but leave record intact + f.seek(end) # back to end of line, to process next lines + ##print("Patched to IGN rec") + + def write(self, filename=None): + """Rewrite the whole in memory cache over the top of the external file""" + #or create a new file if one does not exist + + if filename == None: + filename = self.filename + if filename == None: + raise RuntimeError("No filename configured") + + with open(filename, "w") as f: + # create an ADD record in the file, for each object in the store + + for key in self.store: + obj = self.store[key] + #TODO: for this to work, we need to call the inner object get_config() to get a persistable version + # that the user of this class can recreate it from later + + f.write("ADD %s\n" % key) + state = obj.get_config() # will fail if object does not have this method + for k in state: + f.write("%s=%s\n" % (k, state[k])) + + # terminate with a blank line + f.write("\n") + + self.filename = filename # Remember that we are linked to this file + + +# END + diff --git a/src/energenie/KVS_test.py b/src/energenie/KVS_test.py new file mode 100644 index 0000000..f3c0727 --- /dev/null +++ b/src/energenie/KVS_test.py @@ -0,0 +1,317 @@ +# KVS_test.py 27/05/2016 D.J.Whale +# +# Tester for Key Value Store + +import unittest +from lifecycle import * +from KVS import KVS, NotPersistableError + +#---- DUMMY TEST CLASSES ------------------------------------------------------ + +class TV(): + def __init__(self, id): + print("Creating TV %s" % id) + self.id = id + + def __repr__(self): + return "TV(%s)" % self.id + + def get_config(self): + return { + "id": self.id + } + +class FACTORY(): + @staticmethod + def get(name, **kwargs): + if name == "TV": return TV(**kwargs) + else: + raise ValueError("Unknown device name %s" % name) + + +#----- FILE HELPERS ----------------------------------------------------------- + +#TODO: This is repeated in Registry_test.py + +def remove_file(filename): + import os + try: + os.unlink(filename) + except OSError: + pass # ignore + +def show_file(filename): + """Show the contents of a file on screen""" + with open(filename) as f: + for l in f.readlines(): + l = l.strip() # remove nl + print(l) + +def write_file(filename, contents): + with open(filename, "w") as f: + lines = contents.split("\n") + for line in lines: + f.write(line + '\n') + + +#----- TEST KVS MEMORY -------------------------------------------------------- +# +# Test the KVS in-memory only configuration (no persistence to file) + +class TestKVSMemory(unittest.TestCase): + + @test_1 + def test_create_blank(self): + """Create a blank kvs, not bound to any external file""" + kvs = KVS() + # it should not fall over + + @test_1 + def test_add(self): + """Add an object into the kvs store""" + kvs = KVS() + + kvs["tv1"] = TV(1) + kvs["tv2"] = TV(2) + + print(kvs.store) + + @test_1 + def test_change(self): + """Change the value associated with an existing key""" + kvs = KVS() + kvs["tv1"] = TV(1) + kvs["tv1"] = TV(111) # change it + + print(kvs.store) + + @test_1 + def test_get(self): + """Get the object associated with a key in the store""" + kvs = KVS() + kvs["tv1"] = TV(1) + t = kvs["tv1"] + print(t) + + @test_1 + def test_delete(self): + """Delete an existing key in the store, and a missing key for error""" + kvs = KVS() + kvs["tv1"] = TV(1) + del kvs["tv1"] + print(kvs.store) + + try: + del kvs["tv1"] # expect error + self.fail("Did not get expected KeyError exception") + except KeyError: + pass # expected + + @test_1 + def test_size(self): + """How big is the kvs""" + kvs = KVS() + kvs["tv1"] = TV(1) + print(len(kvs)) + kvs["tv2"] = TV(2) + print(len(kvs)) + + @test_1 + def test_keys(self): + """Get out all keys of the kvs""" + kvs = KVS() + kvs["tv1"] = TV(1) + kvs["tv2"] = TV(2) + kvs["tv3"] = TV(3) + print(kvs.keys()) + + +#----- TEST KVS PERSISTED ----------------------------------------------------- +# +# Test the KVS persisted to a file + +class TestKVSPersisted(unittest.TestCase): + + KVS_FILENAME = "test.kvs" + + @test_1 + def test_write(self): + """Write an in memory KVS to a file""" + remove_file(self.KVS_FILENAME) + kvs = KVS() + kvs["tv1"] = TV(1) + kvs.write(self.KVS_FILENAME) + + show_file(self.KVS_FILENAME) + + @test_1 + def test_load_cache(self): + """Load record from a kvs file into the kvs cache""" + # create a file to test against + remove_file(self.KVS_FILENAME) + kvs = KVS() + kvs["tv1"] = TV(1) + kvs.write(self.KVS_FILENAME) + + kvs = KVS() # clear it out again + + # load the file + kvs.load(self.KVS_FILENAME) + + # check the state of the kvs memory + print(kvs.store) + + # check state of the kvs file at end + show_file(self.KVS_FILENAME) + + @test_1 + def test_add(self): + """Add a new record to a persisted KVS""" + remove_file(self.KVS_FILENAME) + kvs = KVS(self.KVS_FILENAME) + + kvs["tv1"] = TV(1) + + print(kvs.store) + show_file(self.KVS_FILENAME) + + @test_1 + def test_delete(self): + """Delete an existing key from the persistent version""" + + remove_file(self.KVS_FILENAME) + kvs = KVS(self.KVS_FILENAME) + + kvs["tv1"] = TV(1) + kvs["tv2"] = TV(2) + kvs["tv3"] = TV(3) + kvs["tv4"] = TV(4) + + show_file(self.KVS_FILENAME) + + del kvs["tv1"] + + @test_1 + def test_change(self): + """Change an existing record in a persisted KVS""" + remove_file(self.KVS_FILENAME) + kvs = KVS(self.KVS_FILENAME) + + kvs["tv1"] = TV(1) + show_file(self.KVS_FILENAME) + + kvs["tv1"] = TV(2) + show_file(self.KVS_FILENAME) + + @test_1 + def test_ADD_nofactory(self): + #NOTE: This is an under the bonnet test of parsing an ADD record from the file + + # No factory callback provided, use ADD parse action + obj = { + "type": "MIHO005", + "id": 1234 + } + kvs = KVS(self.KVS_FILENAME) + kvs.ADD("tv1", obj) + + # expected result: object described as a kvp becomes a kvp in the store if no factory callback + print(kvs.store) + + @test_1 + def test_ADD_factory(self): + #NOTE: This is an under the bonnet test of parsing an ADD record from the file + obj = { + "type": "TV", + "id": 1234 + } + kvs = KVS(self.KVS_FILENAME) + kvs.ADD("tv1", obj, create_fn=FACTORY.get) + + # expected result: object described as a kvp becomes a configured object instance in store + print(kvs.store) + + + @test_1 + def test_IGN(self): + #NOTE: This is an under the bonnet test of parsing an IGN record from the file + obj = { + "type": "TV", + "id": 1234 + } + kvs = KVS(self.KVS_FILENAME) + kvs.IGN("tv1", obj) + + # expected result: no change to the in memory data structures + print(kvs.store) + + + @test_1 + def test_DEL(self): + #NOTE: This is an under the bonnet test of parsing a DEL record from the file + + #NOTE: This is an under the bonnet test of parsing an IGN record from the file + obj = { + "type": "TV", + "id": 1234 + } + kvs = KVS(self.KVS_FILENAME) + kvs.ADD("tv1", obj) + kvs.DEL("tv1", obj) + + # expected result: record is deleted from in memory store + print(kvs.store) + + + try: + kvs.DEL("tv1", obj) + self.fail("Did not get expected KeyError") + except KeyError: + pass # expected + # expected result: error if it was not in the store in the first place + print(kvs.store) + + + @test_1 + def test_load_process(self): + """Load and process a file with lots of records in it""" + CONTENTS = """\ +ADD tv +type=TV +id=1 + +IGN fan +type=TV +id=2 + +DEL tv + +ADD fridge +type=TV +id=99 +""" + write_file(self.KVS_FILENAME, CONTENTS) + + kvs = KVS(self.KVS_FILENAME) + kvs.load(create_fn=FACTORY.get) + + print(kvs.store) + + @test_1 + def test_not_persistable(self): + class NPC(): + pass + remove_file(self.KVS_FILENAME) + kvs = KVS(self.KVS_FILENAME) + + try: + kvs["npc"] = NPC() # should throw NotPersistableError + self.fail("Did not get expected NotPersistableError") + except NotPersistableError: + pass # expected + + +if __name__ == "__main__": + unittest.main() + +# END \ No newline at end of file diff --git a/src/energenie/Messages.py b/src/energenie/Messages.py deleted file mode 100644 index 778dd69..0000000 --- a/src/energenie/Messages.py +++ /dev/null @@ -1,71 +0,0 @@ -# Message.py 03/04/2015 D.J.Whale -# -# pydict formatted message structures for OpenThings - -try: # python 2 - import Devices - import OpenThings -except ImportError: - from . import Devices - from . import OpenThings - -SWITCH = { - "header": { - "mfrid": Devices.MFRID, - "productid": Devices.PRODUCTID_MIHO005, - "encryptPIP": Devices.CRYPT_PIP, - "sensorid": 0 # FILL IN - }, - "recs": [ - { - "wr": True, - "paramid": OpenThings.PARAM_SWITCH_STATE, - "typeid": OpenThings.Value.UINT, - "length": 1, - "value": 0 # FILL IN - } - ] -} - - -JOIN_ACK = { - "header": { - "mfrid": 0, # FILL IN - "productid": 0, # FILL IN - "encryptPIP": Devices.CRYPT_PIP, - "sensorid": 0 # FILL IN - }, - "recs": [ - { - "wr": False, - "paramid": OpenThings.PARAM_JOIN, - "typeid": OpenThings.Value.UINT, - "length": 0 - } - ] -} - - -REGISTERED_SENSOR = { - "header": { - "mfrid": 0, # FILL IN - "productid": 0, # FILL IN - "encryptPIP": Devices.CRYPT_PIP, - "sensorid": 0 # FILL IN - } -} - - -def send_join_ack(radio, mfrid, productid, sensorid): - # send back a JOIN ACK, so that join light stops flashing - response = OpenThings.alterMessage(JOIN_ACK, - header_mfrid=mfrid, - header_productid=productid, - header_sensorid=sensorid) - p = OpenThings.encode(response) - radio.transmitter() - radio.transmit(p, inner_times=2) - radio.receiver() - - -# END diff --git a/src/energenie/OnAir.py b/src/energenie/OnAir.py new file mode 100644 index 0000000..3b4b9a1 --- /dev/null +++ b/src/energenie/OnAir.py @@ -0,0 +1,172 @@ +# OnAir.py 19/05/2016 D.J.Whale +# +# A set of adaptors to allow device classes to interact with radio interfaces. +# At the moment, the main purpose of this abstraction is to prevent the need +# for device classes to know specifics about radio technology and focus +# on the device aspects only. +# +# In the future, this will be a useful point in the architecture to add +# an intelligent message scheduler, that learns the report patterns of +# specific devices, builds a timeslotted schedule, and schedules transmits +# into mostly free slots. +# +# NOTE: This also might include intelligent power level selection based +# on RSSI reports from different devices. + +##from lifecycle import * +import time + +try: + # Python 2 + import OpenThings + import TwoBit + import radio +except ImportError: + # Python 3 + from . import OpenThings + from . import TwoBit + from . import radio + + +class OpenThingsAirInterface(): + def __init__(self): + self.radio = radio # aids mocking later + + class RadioDefaults(): + frequency = 433.92 + modulation = radio.RADIO_MODULATION_FSK + + class TxDefaults(RadioDefaults): + power_level = 0 + inner_repeats = 4 + outer_delay = 0 + outer_repeats = 0 + self.tx_defaults = TxDefaults() + + class RxDefaults(RadioDefaults): + poll_rate = 100 #ms + timeout = 1000 #ms + self.rx_defaults = RxDefaults() + + ##@log_method + def send(self, payload, radio_params=None): + # payload is a pydict suitable for OpenThings + # radio_params is an overlay on top of radio tx defaults + pass #TODO + p = OpenThings.encode(payload) + #TODO: merge radio_params with self.tx_defaults + #TODO: configure radio modulation based on merged params + radio.transmitter(fsk=True) + #TODO: configure other radio parameters + #TODO: transmit payload + radio.transmit(p, outer_times=1, inner_times=4, outer_delay=0) + # radio auto-returns to previous state after transmit completes + + ##@log_method + def receive(self, radio_params): # -> (radio_measurements, address or None, payload or None) + # radio_params is an overlay on top of radio rx defaults (e.g. poll rate, timeout, min payload, max payload) + # radio_measurements might include rssi reading, short payload report, etc + pass # TODO + #TODO: set radio to receive mode + #TODO: merge radio_params with self.tx_defaults + #TODO: configure radio modulation based on merged params + + #TODO: poll radio at rate until timeout or received + #TODO: start timeout timer + payload = None + radio.receiver(fsk=True) + while True: # timer not expired + if radio.is_receive_waiting(): + payload = radio.receive() #TODO: payload, radio_measurements = radio.receive() + now = time.time() + p = OpenThings.decode(payload, receive_timestamp=now) + #TODO: if crc failure, report it, but keep trying + #if crc check passes... + break + #TODO: inter-try delay + #TODO: return radio to state it was before receiver (e.g. standby) - radio needs a pop() on this too? + + if payload == None: # nothing received in timeout + return (None, None, None) # (radio_measurements, address, payload) #TODO: might be measurements, average min max? + + #TODO: extract addresses: header_manufacturerid, header_productid header_deviceid -> (m, p, d) + m, p, d = None, None, None + radio_measurements = None #TODO: get from radio.receive() + address = (m, p, d) + return (radio_measurements, address, payload) + + +class TwoBitAirInterface(): + def __init__(self): + self.radio = radio # aids mocking later + + class RadioDefaults(): + frequency = 433.92 + modulation = radio.RADIO_MODULATION_OOK + + class TxDefaults(RadioDefaults): + power_level = 0 + inner_repeats = 8 + outer_delay = 0 + outer_repeats = 0 + self.tx_defaults = TxDefaults() + + class RxDefaults(RadioDefaults): + poll_rate = 100 #ms + timeout = 1000 #ms + self.rx_defaults = RxDefaults() + + ##@log_method + def send(self, payload, radio_params=None): + # payload is just a list of bytes, or a byte buffer + # radio_params is an overlay on top of radio tx defaults + + house_address = payload["house_address"] + device_index = payload["device_index"] + state = payload["on"] + bytes = TwoBit.encode_switch_message(state, device_index, house_address) + radio.modulation(ook=True) + + #TODO: merge radio_params with self.tx_defaults + #TODO: configure radio modulation based on merged params + #TODO: transmit payload + + radio.transmit(bytes, outer_times=1, inner_times=8, outer_delay=0) #TODO: radio params + # radio auto-pops to state before transmit + + ##@log_method + def receive(self, radio_params): # -> (radio_measurements, address or None, payload or None) + # radio_params is an overlay on top of radio rx defaults (e.g. poll rate, timeout, min payload, max payload) + # radio_measurements might include rssi reading, short payload report, etc + #TODO: merge radio_params with self.tx_defaults + #TODO: configure radio modulation based on merged params + + #TODO: poll radio at rate until timeout or received + #TODO: start timeout timer + payload = None + radio.receiver(ook=True) + while True: # timer not expired + if radio.is_receive_waiting(): + #TODO: radio config should set receive preamble 4 bytes to prevent false triggers + payload = radio.receive(size=12) #TODO: payload, radio_measurements = radio.receive() + p = TwoBit.decode(payload) + #TODO: if failure, report it, but keep trying + #if check passes... + break + #TODO: inter-try delay + #TODO: return radio to state it was before receiver (e.g. standby) - radio needs a pop() on this too? + + if payload == None: # nothing received in timeout + return (None, None, None) # (radio_measurements, address, payload) #TODO: might be measurements, average min max? + + #TODO: extract addresses (house_address, device_index) + radio_measurements = None #TODO: return this from radio.receive() + h = 0xC8C8C #TODO: Get house address from TwoBit.decode()[:10] + d = 0xEE #TODO: Get device command from TwoBit.decode()[11:12] + address = (h, d) + return (radio_measurements, address, payload) + + +# END + + diff --git a/src/energenie/OpenThings.py b/src/energenie/OpenThings.py index eecf089..a1cf501 100644 --- a/src/energenie/OpenThings.py +++ b/src/energenie/OpenThings.py @@ -2,11 +2,24 @@ # # Implement OpenThings message encoding and decoding +##from lifecycle import * import time + try: - import crypto # python 2 + # Python 2 + import crypto except ImportError: - from . import crypto # python 3 + # Python 3 + from . import crypto + + +def warning(msg): + print("warning:" + str(msg)) + + +def trace(msg): + print("OpenThings:%s" % str(msg)) + class OpenThingsException(Exception): def __init__(self, value): @@ -16,6 +29,17 @@ return repr(self.value) +#----- CRYPT PROCESSING ------------------------------------------------------- + +crypt_pid = None + +def init(pid): + global crypt_pid + crypt_pid = pid + + +#----- PARAMETERS ------------------------------------------------------------- + # report has bit 7 clear # command has bit 7 set @@ -114,28 +138,30 @@ } -crypt_pid = None - -def init(pid): - global crypt_pid - crypt_pid = pid +def paramname_to_paramid(paramname): + """Turn a parameter name to a parameter id number""" + for paramid in param_info: + name = param_info[paramid]['n'] # name + if name == paramname: + return paramid + raise ValueError("Unknown param name %s" % paramname) -def warning(msg): - print("warning:" + str(msg)) - - -def trace(msg): - print("OpenThings:%s" % str(msg)) +def paramid_to_paramname(paramid): + """Turn a parameter id number into a parameter name""" + try: + return param_info[paramid]['n'] + except KeyError: + return "UNKNOWN_%s" % str(hex(paramid)) #----- MESSAGE DECODER -------------------------------------------------------- -#TODO if silly lengths or silly types seen in decode, this might imply +#TODO: if silly lengths or silly types seen in decode, this might imply #we're trying to process an encrypted packet without decrypting it. #the code should be more robust to this (by checking the CRC) -def decode(payload, decrypt=True): +def decode(payload, decrypt=True, receive_timestamp=None): """Decode a raw buffer into an OpenThings pydict""" #Note, decrypt must already have run on this for it to work length = payload[0] @@ -143,12 +169,12 @@ # CHECK LENGTH if length+1 != len(payload) or length < 10: raise OpenThingsException("bad payload length") - #return { - # "type": "BADLEN", - # "len_actual": len(payload), - # "len_expected": length, - # "payload": payload[1:] - #} + ##return { + ## "type": "BADLEN", + ## "len_actual": len(payload), + ## "len_expected": length, + ## "payload": payload[1:] + ##} # DECODE HEADER mfrId = payload[1] @@ -166,7 +192,7 @@ # [0]len,mfrid,productid,pipH,pipL,[5] crypto.init(crypt_pid, encryptPIP) crypto.cryptPayload(payload, 5, len(payload)-5) # including CRC - #printhex(payload) + ##printhex(payload) # sensorId is in encrypted region sensorId = (payload[5]<<16) + (payload[6]<<8) + payload[7] header["sensorid"] = sensorId @@ -175,16 +201,16 @@ # CHECK CRC crc_actual = (payload[-2]<<8) + payload[-1] crc_expected = calcCRC(payload, 5, len(payload)-(5+2)) - #trace("crc actual:%s, expected:%s" %(hex(crc_actual), hex(crc_expected))) + ##trace("crc actual:%s, expected:%s" %(hex(crc_actual), hex(crc_expected))) if crc_actual != crc_expected: raise OpenThingsException("bad CRC") - #return { - # "type": "BADCRC", - # "crc_actual": crc_actual, - # "crc_expected": crc_expected, - # "payload": payload[1:], - #} + ##return { + ## "type": "BADCRC", + ## "crc_actual": crc_actual, + ## "crc_expected": crc_expected, + ## "payload": payload[1:], + ##} # DECODE RECORDS @@ -230,11 +256,14 @@ # store rec recs.append(rec) - return { + m = { "type": "OK", "header": header, "recs": recs } + if receive_timestamp != None: + m["rxtimestamp"] = receive_timestamp + return Message(m) #----- MESSAGE ENCODER -------------------------------------------------------- @@ -359,13 +388,13 @@ mask = 1<<(maxbits-1) bitno = maxbits-1 while mask != 0: - #trace("compare %s with %s" %(hex(value), hex(mask))) + ##trace("compare %s with %s" %(hex(value), hex(mask))) if (value & mask) == 0: - #trace("zero at bit %d" % bitno) + ##trace("zero at bit %d" % bitno) return bitno mask >>= 1 bitno-=1 - #trace("not found") + ##trace("not found") return None # NOT FOUND @@ -377,25 +406,25 @@ if value == -1: # always 0xFF, so always needs exactly 2 bits to represent (sign and value) return 2 # bits required - #trace("valuebits of:%d" % value) + ##trace("valuebits of:%d" % value) # Turn into a 2's complement representation MAXBYTES=15 MAXBITS = 1<<(MAXBYTES*8) - #TODO check for truncation? + #TODO: check for truncation? value = value & MAXBITS-1 - #trace("hex:%s" % hex(value)) + ##trace("hex:%s" % hex(value)) highz = Value.highestClearBit(value, MAXBYTES*8) - #trace("highz at bit:%d" % highz) + ##trace("highz at bit:%d" % highz) # allow for a sign bit, and bit numbering from zero neededbits = highz+2 - #trace("needed bits:%d" % neededbits) + ##trace("needed bits:%d" % neededbits) return neededbits @staticmethod def encode(value, typeid, length=None): - #trace("encoding:" + str(value)) + ##trace("encoding:" + str(value)) if typeid == Value.CHAR: if type(value) != str: value = str(value) @@ -461,15 +490,15 @@ bits = Value.valuebits(value) else: bits = Value.typebits(typeid) - #trace("need bits:" + str(bits)) + ##trace("need bits:" + str(bits)) # NORMALISE BITS TO BYTES - ####HERE#### round up to nearest number of 8 bits + #round up to nearest number of 8 bits # if already 8, leave 1,2,3,4,5,6,7,8 = 8 0,1,2,3,4,5,6,7 (((b-1)/8)+1)*8 # 9,10,11,12,13,14,15,16=16 bits = (((bits-1)/8)+1)*8 # snap to nearest byte boundary - #trace("snap bits to 8:" + str(bits)) + ##trace("snap bits to 8:" + str(bits)) - value &= ((2**bits)-1) + value &= ((2**int(bits))-1) neg = True else: neg = False @@ -544,22 +573,6 @@ #----- CRC CALCULATION -------------------------------------------------------- -#int16_t crc(uint8_t const mes[], unsigned char siz) -#{ -# uint16_t rem = 0; -# unsigned char byte, bit; -# -# for (byte = 0; byte < siz; ++byte) -# { -# rem ^= (mes[byte] << 8); -# for (bit = 8; bit > 0; --bit) -# { -# rem = ((rem & (1 << 15)) ? ((rem << 1) ^ 0x1021) : (rem << 1)); -# } -# } -# return rem; -#} - def calcCRC(payload, start, length): rem = 0 for b in payload[start:start+length]: @@ -574,200 +587,284 @@ return rem -def showMessage(msg, timestamp=None): - """Show the message in a friendly format""" +#----- MESSAGE UTILITIES ------------------------------------------------------ - # HEADER - header = msg["header"] - mfrid = header["mfrid"] - productid = header["productid"] - sensorid = header["sensorid"] - if timestamp != None: - print("receive-time:%s" % time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp))) - print("mfrid:%s prodid:%s sensorid:%s" % (hex(mfrid), hex(productid), hex(sensorid))) +# SAMPLE MESSAGE +# 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 +# } +# ] +# } - # RECORDS - for rec in msg["recs"]: - wr = rec["wr"] - if wr == True: - write = "write" + +import copy + +class Message(): + BLANK = { + "header": { + "mfrid" : None, + "productid": None, + "sensorid": None + }, + "recs":[] + } + + def __init__(self, pydict=None, **kwargs): + if pydict == None: + pydict = copy.deepcopy(Message.BLANK) + self.pydict = pydict + + self.set(**kwargs) + + def __getitem__(self, key): + try: + # an integer key is used as a paramid in recs[] + key = int(key) + except: + # not an integer, so do a normal key lookup + # typically used for msg["header"] and msg["recs"] + # just returns a reference to that part of the inner pydict + return self.pydict[key] + + # Is an integer, so index into recs[] + ##print("looking up in recs") + for rec in self.pydict["recs"]: + if "paramid" in rec: + paramid = rec["paramid"] + if paramid == key: + return rec + raise KeyError("no paramid found for %s" % str(hex(key))) + + + def __setitem__(self, key, value): + """set the header or the recs to the provided value""" + try: + key = int(key) + except: + # Not an parseable integer, so access by field name + ##print("set by key") + self.pydict[key] = value + return + + # Is an integer, so index into recs[] + ##print("looking up in recs") + i = 0 + for rec in self.pydict["recs"]: + if "paramid" in rec: + paramid = rec["paramid"] + if paramid == key: + ##print("found at index %d %s" % (i, rec)) + # add in the paramid + value["paramid"] = key + self.pydict["recs"][i] = value + return + i += 1 + + # Not found, so we should add it + print("no paramid for key %s, adding..." % str(hex(key))) + #TODO: add + # add in the paramid + value["paramid"] = key + self.pydict["recs"].append(value) + + def copyof(self): # -> Message + """Clone, to create a new message that is a completely independent copy""" + import copy + return Message(copy.deepcopy(self.pydict)) + + def set(self, **kwargs): + """Set fields in the message from key value pairs""" + for key in kwargs: + value = kwargs[key] + # Is it a recs_PARAM_NAME_value format? + if key.startswith('recs_') and len(key)>6 and key[6].isupper(): + self.set_PARAM_NAME(key[5:], value) + else: + # It's a full path + pathed_key = key.split('_') + m = self.pydict + # walk to the parent + for pkey in pathed_key[:-1]: + # If the key parseable as an integer, use as a list index instead + try: + pkey = int(pkey) + except: + pass + m = m[pkey] + # set the parent to have a key that points to the new value + pkey = pathed_key[-1] + # If the key parseable as an integer, use as a list index instead + try: + pkey = int(pkey) + except: + pass + + # if index exists, change it, else create it + try: + m[pkey] = value + except IndexError: + # expand recs up to pkey + l = len(m) # length of list + gap = (l - pkey)+1 + for i in range(gap): + m.append({}) + m[pkey] = value + + def set_PARAM_NAME(self, key, value): + """Set a parameter given a PARAM_NAME key like recs_PARAM_NAME_field_nae""" + ##print("set param name %s %s" % (key, value)) + ##key='recs_SWITCH_STATE' + #e.g. recs_SWITCH_STATE_value + #scan forward from char 5 until first lower case char, or end + pos = 0 + last_uc=None + for c in key: + pos += 1 + if c.isupper(): + last_uc = pos + if c.islower(): break + name = key[:last_uc] + + # turn PARAM_NAME into an integer id + param_id = paramname_to_paramid(name) + ##print("paramid %d" % param_id) + + # search for the id as a rec[]["paramid":v] value and get the rec + found = False + pos = 0 + for rec in self.pydict["recs"]: + if "paramid" in rec: + if rec["paramid"] == param_id: + ##print("found: %s" % rec) + found = True + break + pos += 1 + + if not found: + raise ValueError("No such paramid in message: %s" % name) + + # is this rec_PARAM_NAME or rec_PARAM_NAME_field_name?? + if len(key) == len(name): + ##print("REC") + value["paramid"] = param_id + self.pydict["recs"][pos] = value + else: - write = "read " + ##print("REC with field") + field_key = key[len(name)+1:] + self.pydict["recs"][pos][field_key] = value - paramid = rec["paramid"] - paramname = rec["paramname"] - paramunit = rec["paramunit"] - if "value" in rec: - value = rec["value"] + def append_rec(self, *args, **kwargs): + """Add a rec""" + if type(args[0]) == dict: + # This is ({}) + self.pydict["recs"].append(args[0]) + return len(self.pydict["recs"])-1 # index of rec just added + + elif type(args[0]) == int: + if len(kwargs) == 0: + # This is (PARAM_x, pydict) + paramid = args[0] + pydict = args[1] + pydict["paramid"] = paramid + return self.append_rec(pydict) + else: + # This is (PARAM_x, key1=value1, key2=value2) + paramid = args[0] + # build a pydict + pydict = {"paramid":paramid} + for key in kwargs: + value = kwargs[key] + pydict[key] = value + self.append_rec(pydict) + else: - value = None - print("%s %s %s = %s" % (write, paramname, paramunit, str(value))) + raise ValueError("Not sure how to parse arguments to append_rec") - -def alterMessage(message, **kwargs): - """Change parameters in-place in a message template""" - # e.g. header_sensorid=1234, recs_0_value=1 - for arg in kwargs: - - path = arg.split("_") - value = kwargs[arg] - - m = message - for p in path[:-1]: + def get(self, keypath): + """READ(GET) from a single keypathed entry""" + path = keypath.split("_") + m = self.pydict + # walk to the final item + for pkey in path: try: - p = int(p) + pkey = int(pkey) except: pass - m = m[p] - #trace("old value:%s" % m[path[-1]]) - m[path[-1]] = value + m = m[pkey] + return m - #trace("modified:" + str(message)) + def __str__(self): # -> str + return str(self.pydict) - return message + def dump(self): + msg = self.pydict + timestamp = None + # TIMESTAMP + if timestamp != None: + print("receive-time:%s" % time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp))) -def getFromMessage(message, keypath): - """Get a field from a message, given an underscored keypath to the item""" - path = keypath.split("_") + # HEADER + if "header" in msg: + header = msg["header"] - for p in path[:-1]: - try: - p = int(p) - except: - pass - message = message[p] - return message[path[-1]] + def gethex(key): + if key in header: + value = header[key] + if value != None: return str(hex(value)) + return "" + mfrid = gethex("mfrid") + productid = gethex("productid") + sensorid = gethex("sensorid") -#----- TEST HARNESS ----------------------------------------------------------- + print("mfrid:%s prodid:%s sensorid:%s" % (mfrid, productid, sensorid)) -def printhex(payload): - line = "" - for b in payload: - line += hex(b) + " " + # RECORDS + if "recs" in msg: + for rec in msg["recs"]: + wr = rec["wr"] + if wr == True: + write = "write" + else: + write = "read " - print(line) + try: + paramname = rec["paramname"] #NOTE: This only comes out from decoded messages + except: + paramname = "" + try: + paramid = rec["paramid"] #NOTE: This is only present on a input message (e.g SWITCH) + paramname = paramid_to_paramname(paramid) + paramid = str(hex(paramid)) + except: + paramid = "" -TEST_PAYLOAD = [ - 0x1C, #len 16 + 10 + 2 = 0001 1100 - 0x04, #mfrid - 0x02, #prodid - 0x01, #pipmsb - 0x00, #piplsb - 0x00, 0x06, 0x8B, #sensorid - 0x70, 0x82, 0x00, 0x07, #SINT(2) power - 0x71, 0x82, 0xFF, 0xFD, #SINT(2) reactive_power - 0x76, 0x01, 0xF0, #UINT(1) voltage - 0x66, 0x22, 0x31, 0xDA, #UINT_BP8(2) freq - 0x73, 0x01, 0x01, #UINT(1) switch_state - 0x00, #NUL - 0x97, 0x64 #CRC + try: + paramunit = rec["paramunit"] #NOTE: This only comes out from decoded messages + except: + paramunit = "" -] + if "value" in rec: + value = rec["value"] + else: + value = None -import pprint + print("%s %s %s %s = %s" % (write, paramid, paramname, paramunit, str(value))) -def test_payload_unencrypted(): - init(242) - - printhex(TEST_PAYLOAD) - spec = decode(TEST_PAYLOAD, decrypt=False) - pprint.pprint(spec) - - payload = encode(spec, encrypt=False) - printhex(payload) - - spec2 = decode(payload, decrypt=False) - pprint.pprint(spec2) - - payload2 = encode(spec2, encrypt=False) - - printhex(TEST_PAYLOAD) - printhex(payload2) - - if TEST_PAYLOAD != payload: - print("FAILED") - else: - print("PASSED") - - -def test_payload_encrypted(): - init(242) - - printhex(TEST_PAYLOAD) - spec = decode(TEST_PAYLOAD, decrypt=False) - pprint.pprint(spec) - - payload = encode(spec, encrypt=True) - printhex(payload) - - spec2 = decode(payload, decrypt=True) - pprint.pprint(spec2) - - payload2 = encode(spec2, encrypt=False) - - printhex(TEST_PAYLOAD) - printhex(payload2) - - if TEST_PAYLOAD != payload: - print("FAILED") - else: - print("PASSED") - - -def test_value_encoder(): - pass - # test cases (auto, forced, overflow, -min, -min-1, 0, 1, +max, +max+1 - # UINT - # UINT_BP4 - # UINT_BP8 - # UINT_BP12 - # UINT_BP16 - # UINT_BP20 - # UINT_BP24 - # SINT - # SINT(2) - vin = [1,255,256,32767,32768,0,-1,-2,-3,-127,-128,-129,-32767,-32768] - for v in vin: - vout = Value.encode(v, Value.SINT) - print("encode " + str(v) + " " + str(vout)) - # SINT_BP8 - # SINT_BP16 - # SINT_BP24 - # CHAR - # FLOAT - - -def test_value_decoder(): - pass - # test cases (auto, forced, overflow, -min, -min-1, 0, 1, +max, +max+1 - # UINT - # UINT_BP4 - # UINT_BP8 - # UINT_BP12 - # UINT_BP16 - # UINT_BP20 - # UINT_BP24 - # SINT - vin = [255, 253] - print("input value:" + str(vin)) - vout = Value.decode(vin, Value.SINT, 2) - print("encoded as:" + str(vout)) - - # SINT_BP8 - # SINT_BP16 - # SINT_BP24 - # CHAR - # FLOAT - - -if __name__ == "__main__": - #test_value_encoder() - #test_value_decoder() - test_payload_unencrypted() - #test_payload_encrypted() - # END diff --git a/src/energenie/OpenThings_test.py b/src/energenie/OpenThings_test.py new file mode 100644 index 0000000..986dd46 --- /dev/null +++ b/src/energenie/OpenThings_test.py @@ -0,0 +1,470 @@ +# OpenThings_test.py 21/05/2016 D.J.Whale +# +# Test harness for OpenThings protocol encoder and decoder + +import pprint +import unittest +from lifecycle import * + +from OpenThings import * +import Devices + + +def printhex(payload): + line = "" + for b in payload: + line += hex(b) + " " + + print(line) + + +TEST_PAYLOAD = [ + 0x1C, #len 16 + 10 + 2 = 0001 1100 + 0x04, #mfrid + 0x02, #prodid + 0x01, #pipmsb + 0x00, #piplsb + 0x00, 0x06, 0x8B, #sensorid + 0x70, 0x82, 0x00, 0x07, #SINT(2) power + 0x71, 0x82, 0xFF, 0xFD, #SINT(2) reactive_power + 0x76, 0x01, 0xF0, #UINT(1) voltage + 0x66, 0x22, 0x31, 0xDA, #UINT_BP8(2) freq + 0x73, 0x01, 0x01, #UINT(1) switch_state + 0x00, #NUL + 0x97, 0x64 #CRC + +] + + +class TestPayloads(unittest.TestCase): + @test_1 + def test_payload_unencrypted(self): + init(242) + + printhex(TEST_PAYLOAD) + spec = decode(TEST_PAYLOAD, decrypt=False) + pprint.pprint(spec) + + payload = encode(spec, encrypt=False) + printhex(payload) + + spec2 = decode(payload, decrypt=False) + pprint.pprint(spec2) + + payload2 = encode(spec2, encrypt=False) + + printhex(TEST_PAYLOAD) + printhex(payload2) + + if TEST_PAYLOAD != payload: + print("FAILED") + else: + print("PASSED") + + @test_1 + def test_payload_encrypted(self): + init(242) + + printhex(TEST_PAYLOAD) + spec = decode(TEST_PAYLOAD, decrypt=False) + pprint.pprint(spec) + + payload = encode(spec, encrypt=True) + printhex(payload) + + spec2 = decode(payload, decrypt=True) + pprint.pprint(spec2) + + payload2 = encode(spec2, encrypt=False) + + printhex(TEST_PAYLOAD) + printhex(payload2) + + if TEST_PAYLOAD != payload: + print("FAILED") + else: + print("PASSED") + + @test_1 + def test_value_encoder(self): + pass + # test cases (auto, forced, overflow, -min, -min-1, 0, 1, +max, +max+1 + # UINT + # UINT_BP4 + # UINT_BP8 + # UINT_BP12 + # UINT_BP16 + # UINT_BP20 + # UINT_BP24 + # SINT + # SINT(2) + vin = [1,255,256,32767,32768,0,-1,-2,-3,-127,-128,-129,-32767,-32768] + for v in vin: + vout = Value.encode(v, Value.SINT) + print("encode " + str(v) + " " + str(vout)) + # SINT_BP8 + # SINT_BP16 + # SINT_BP24 + # CHAR + # FLOAT + + @test_1 + def test_value_decoder(self): + pass + # test cases (auto, forced, overflow, -min, -min-1, 0, 1, +max, +max+1 + # UINT + # UINT_BP4 + # UINT_BP8 + # UINT_BP12 + # UINT_BP16 + # UINT_BP20 + # UINT_BP24 + # SINT + vin = [255, 253] + print("input value:" + str(vin)) + vout = Value.decode(vin, Value.SINT, 2) + print("encoded as:" + str(vout)) + + # SINT_BP8 + # SINT_BP16 + # SINT_BP24 + # CHAR + # FLOAT + + +#----- UNIT TEST FOR MESSAGE -------------------------------------------------- +# ACCESSOR VARIANTS +# as method parameters: +# 1 {pydict} +# +# 2 header={pydict} +# 3 header_mfrid=123 +# 4 recs_0={pydict} +# 5 recs_0_paramid=PARAM_SWITCH_STATE +# 6 SWITCH_STATE={pydict} +# 7 SWITCH_STATE,value=1 +# 8 SWITCH_STATE_value=1 +# +# as attribute accessors +# 9 msg["header"] msg["recs"][0] +# 10 msg[PARAM_SWITCH_STATE] + + +#TODO: Break dependence on Devices.MIHO005_REPORT (makes tests brittle) + +class TestMessage(unittest.TestCase): + + @test_1 + def test_blank(self): + """CREATE a completely blank message""" + msg = Message() + print(msg) + msg.dump() + + @test_1 + def test_blank_template(self): + """CREATE a message from a simple pydict template""" + # This is useful to simplify all other tests + msg = Message(Message.BLANK) + print(msg) + msg.dump() + + @test_1 + def test_blank_create_dict(self): #1 {pydict} + """CREATE a blank message with a dict parameter""" + msg = Message({"header":{}, "recs":[{"wr":False, "parmid":PARAM_SWITCH_STATE, "value":1}]}) + print(msg) + msg.dump() + + @test_1 + def test_blank_create_header_dict(self): #2 header={pydict} + """CREATE a blank message and add a header at creation time from a dict""" + msg = Message(header={"mfrid":123, "productid":456, "sensorid":789}) + print(msg) + msg.dump() + + @test_1 + def test_create_big_template(self): #1 {pydict} + """CREATE from a large template message""" + # create a message from a template + msg = Message(Devices.MIHO005_REPORT) + print(msg) + msg.dump() + + @test_1 + def test_add_rec_dict(self): #1 {pydict} + """UPDATE(APPEND) rec fields from a dict parameter""" + msg = Message(Message.BLANK) + i = msg.append_rec({"paramid":PARAM_SWITCH_STATE, "wr":True, "value":1}) + print("added index:%d" % i) + print(msg) + msg.dump() + + @test_1 + def test_add_header_dict(self): #2 header={pydict} + """UPDATE(SET) a new header to an existing message""" + msg = Message() + msg.set(header={"mfrid":123, "productid":456, "sensorid":789}) + print(msg) + msg.dump() + + @test_1 + def test_add_recs_dict(self): + """UPDATE(SET) recs to an existing message""" + msg = Message() + msg.set(recs=[{"paramid":PARAM_SWITCH_STATE, "wr":True, "value":1}]) + print(msg) + msg.dump() + + @test_1 + def test_add_path(self): + """UPDATE(SET) a pathed key to an existing message""" + msg = Message() + msg.set(header_productid=1234) + print(msg) + msg.dump() + + @test_1 + def test_alter_template(self): #3 header_mfrid=123 + """UPDATE(SET) an existing key with a path""" + msg = Message(Devices.MIHO005_REPORT) + msg.set(header_productid=123) + print(msg) + msg.dump() + + @test_1 + def test_alter_template_multiple(self): + """UPDATE(SET) multiple keys with paths""" + msg = Message(Devices.MIHO005_REPORT) + msg.set(header_productid=123, header_sensorid=99) + print(msg) + msg.dump() + + @test_1 + def test_blank_create_header_paths(self): #3 header_mfrid=123 (CREATE) + """CREATE message with pathed keys in constructor""" + msg = Message(header_mfrid=123, header_productid=456, header_sensorid=789) + print(msg) + msg.dump() + + @test_1 + def test_blank_create_recs_paths(self): + """CREATE message with pathed keys in constructor""" + # uses integer path component to mean array index + msg = Message(recs_0={"paramid":PARAM_SWITCH_STATE, "wr":True, "value":1}, + recs_1={"paramid":PARAM_AIR_PRESSURE, "wr":True, "value":2}) + print(msg) + msg.dump() + + @test_1 + def test_add_rec_path(self): #5 recs_0_paramid=PARAM_SWITCH_STATE + """UPDATE(SET) records in a message""" + msg = Message(recs_0={}) # must create rec before you can change it + print(msg.pydict) + msg.set(recs_0_paramid=PARAM_SWITCH_STATE, recs_0_value=1, recs_0_wr=True) + print(msg) + msg.dump() + + @test_1 + def test_add_rec_fn_pydict(self): #6 SWITCH_STATE={pydict} + """UPDATE(ADD) a rec to message using PARAM constants as keys""" + #always creates a new rec at the end and then populates + msg = Message() + msg.append_rec(PARAM_SWITCH_STATE, {"wr": True, "value":1}) + print(msg) + msg.dump() + + @test_1 + def test_add_rec_fn_keyed(self): #7 SWITCH_STATE,value=1 (ADD) + """UPDATE(ADD) a rec to message using PARAM const and keyed values""" + msg = Message() + msg.append_rec(PARAM_SWITCH_STATE, wr=True, value=1) + print(msg) + msg.dump() + + @test_1 + def test_get_pathed(self): + """READ from the message with a path key""" + msg = Message(Devices.MIHO005_REPORT) + print(msg.get("header_mfrid")) + + @test_1 + def test_attr_header(self): #9 msg["header"] msg["recs"][0] + """READ(attr) the header""" + # access a specific keyed entry like a normal pydict, for read + msg = Message(Devices.MIHO005_REPORT) + print(msg["header"]) + + @test_1 + def test_attr_header_field(self): #9 msg["header"] msg["recs"][0] + """READ(attr) a field within the header""" + # access a specific keyed entry like a normal pydict, for read + msg = Message(Devices.MIHO005_REPORT) + print(msg["header"]["mfrid"]) + + @test_1 + def test_attr_recs(self): #9 msg["header"] msg["recs"][0] + """READ(attr) all recs""" + # access a specific keyed entry like a normal pydict, for read + msg = Message(Devices.MIHO005_REPORT) + print(msg["recs"]) + + @test_1 + def test_attr_rec(self): #9 msg["header"] msg["recs"][0] + """READ(attr) a single reg""" + # access a specific keyed entry like a normal pydict, for read + msg = Message(Devices.MIHO005_REPORT) + print(msg["recs"][0]) + + @test_1 + def test_attr_rec_field(self): #9 msg["header"] msg["recs"][0] + """READ(attr) a field in a rec""" + # access a specific keyed entry like a normal pydict, for read + msg = Message(Devices.MIHO005_REPORT) + print(msg["recs"][0]["value"]) + + @test_1 + def test_setattr_header(self): + """UPDATE(SET) the header from a setattr key index""" + msg = Message(Devices.MIHO005_REPORT) + msg["header"] = {"mfrid":111, "productid":222, "sensorid":333} + print(msg) + + @test_1 + def test_setattr_header_field_overwrite(self): + """UPDATE(SET) overwrite an existing header field""" + msg = Message(Devices.MIHO005_REPORT) + msg["header"]["productid"] = 999 + print(msg) + + @test_1 + def test_setattr_header_field_add(self): + """UPDATE(SET) add a new header field""" + msg = Message(Devices.MIHO005_REPORT) + msg["header"]["timestamp"] = 1234 + print(msg) + + @test_1 + def test_setattr_recs(self): + """UPDATE(SET) overwrite all recs""" + msg = Message(Devices.MIHO005_REPORT) + msg["recs"] = [ {"paramid":PARAM_SWITCH_STATE, "wr":True, "value":1}, + {"paramid":PARAM_AIR_PRESSURE, "wr":True, "value":33}] + print(msg) + + @test_1 + def test_setattr_rec_overwrite(self): + """UPDATE(SET) overwrite a single rec""" + msg = Message(Devices.MIHO005_REPORT) + msg["recs"][0] = {"paramid":9999, "wr":False, "value":9999} + print(msg) + + @test_1 + def test_setattr_rec_append(self): + """UPDATE(SET) add a new rec by appending""" + msg = Message(Devices.MIHO005_REPORT) + msg["recs"].append({"paramid":9999, "wr":True, "value":9999}) + print(msg) + + @test_1 + def test_setattr_rec_field_overwrite(self): + """UPDATE(SET) overwrite an existing rec field""" + msg = Message(Devices.MIHO005_REPORT) + msg["recs"][0]["value"] = 9999 + print(msg) + + @test_1 + def test_setattr_rec_field_append(self): + """UPDATE(SET) append a new field""" + msg = Message(Devices.MIHO005_REPORT) + msg["recs"][0]["colour"] = "**RED**" + print(msg) + + @test_1 + def test_paramid_read_rec(self): #10 msg[PARAM_SWITCH_STATE] + """READ a rec field keyed by the PARAMID""" + #This triggers a sequential search through the recs for the first matching paramid + msg = Message(Devices.MIHO005_REPORT) + print(msg[PARAM_SWITCH_STATE]) + + @test_1 + def test_paramid_read_missing_rec(self): #10 msg[PARAM_SWITCH_STATE] + """READ a rec field keyed by the PARAMID""" + #This triggers a sequential search through the recs for the first matching paramid + msg = Message(Devices.MIHO005_REPORT) + try: + print(msg[PARAM_AIR_PRESSURE]) + self.fail("Did not get expected exception") + except Exception as e: + pass #expected + + @test_1 + def test_paramid_read_field(self): + """READ a field within a parameter rec""" + msg = Message(Devices.MIHO005_REPORT) + print(msg[PARAM_SWITCH_STATE]["value"]) + + @test_1 + def test_paramid_write_rec_overwrite(self): + """WRITE (overwrite) a whole paramid rec""" + msg = Message(recs_0={"paramid":PARAM_SWITCH_STATE, "wr":True, "value":33}) + ##print(msg) + msg[PARAM_SWITCH_STATE] = {"wr":True, "value":99} + ##print(msg) + print(msg[PARAM_SWITCH_STATE]) + + @test_1 + def test_paramid_write_rec_add(self): + """WRITE (add) a whole paramid rec""" + msg = Message(Devices.MIHO005_REPORT) + msg[PARAM_AIR_PRESSURE] = {"wr":True, "value":1} + ##print(msg) + print(msg[PARAM_AIR_PRESSURE]) + + @test_1 + def test_paramid_write_field_change(self): + """WRITE (change) a field in a paramid rec""" + msg = Message(recs_0={"paramid":PARAM_SWITCH_STATE, "wr":True, "value":33}) + msg[PARAM_SWITCH_STATE]["value"] = 123 + print(msg) + + @test_1 + def test_paramid_write_field_add(self): + """WRITE(add) a field to a paramid rec""" + msg = Message(Devices.MIHO005_REPORT) + msg[PARAM_SWITCH_STATE]["colour"] = "***RED***" + print(msg) + + @test_1 + def test_repr(self): + ## dump a message in printable format + msg = Message(Devices.MIHO005_REPORT) + print(msg) + + @test_1 + def test_str(self): + ## dump a message in printable format + msg = Message(Devices.MIHO005_REPORT) + print(str(msg)) + + @test_1 + def test_alter_PARAM_NAME_rec(self): #8 SWITCH_STATE_value=1 + """UPDATE(alter) a complete rec from a PARAM_NAME index""" + msg = Message(Message.BLANK) + msg[PARAM_SWITCH_STATE] = {"wr":True, "value":42} + msg.set(recs_SWITCH_STATE={"wr":False, "value":99}) + print(msg) + + @test_1 + def test_alter_PARAM_NAME_field(self): #8 SWITCH_STATE_value=1 + """UPDATE(alter) a rec field from a PARAM_NAME index""" + msg = Message(Message.BLANK) + msg[PARAM_SWITCH_STATE] = {"wr":True, "value":42} + msg.set(recs_SWITCH_STATE_value=22) + print(msg) + + +if __name__ == "__main__": + unittest.main() + +# END \ No newline at end of file diff --git a/src/energenie/Registry.py b/src/energenie/Registry.py index c2ffae1..46a1a71 100644 --- a/src/energenie/Registry.py +++ b/src/energenie/Registry.py @@ -4,50 +4,336 @@ # # NOTE: This is an initial, non persisted implementation only -import time +##from lifecycle import * + try: - import Devices # python 2 + # Python 2 + import Devices + import OpenThings + from KVS import KVS except ImportError: - from . import Devices # python 3 - -directory = {} - -def allkeys(d): - result = "" - for k in d: - if len(result) != 0: - result += ',' - result += str(k) - return result + # Python 3 + from . import Devices + from . import OpenThings + from .KVS import KVS -def update(message): - """Update the local directory with information about this device""" - now = time.time() - header = message["header"] - sensorId = header["sensorid"] - if not (sensorId in directory): - # new device discovered - desc = Devices.getDescription(header["mfrid"], header["productid"]) - print("ADD device:%s %s" % (hex(sensorId), desc)) - directory[sensorId] = {"header": message["header"]} - #trace(allkeys(directory)) +#----- NEW DEVICE REGISTRY ---------------------------------------------------- - directory[sensorId]["time"] = now - #TODO would be good to keep recs, but need to iterate through all and key by paramid, - #not as a list index, else merging will be hard. +# Done as a class, so we can have multiple registries if we want. + +class DeviceRegistry(): # this is actions, so is this the 'RegistRAR'?? + """A persistent registry for device class instance configurations""" + + DEFAULT_FILENAME = "registry.kvs" + + def __init__(self, filename=None): + ##print("***Opening DeviceRegistry") + self.store = KVS(filename) + self.fsk_router = None + + def set_fsk_router(self, fsk_router): + self.fsk_router = fsk_router + + def load_from(self, filename=None): + """Start with a blank in memory registry, and load from the given filename""" + if filename == None: filename = DeviceRegistry.DEFAULT_FILENAME + # Create a new in memory store, effectively removing any existing in memory device class instances + #TODO: Not good if there are routes to those class instances? + self.store = KVS(filename) #TODO: later we might make it possible to load_from multiple files + self.store.load(filename, Devices.DeviceFactory.get_device_from_name) + + def load_into(self, context): + """auto-create variables in the provided context, for all persisted registry entries""" + if context == None: + raise ValueError("Must provide a context to hold new variables") + + for name in self.store.keys(): + c = self.get(name) + # This creates a variable inside the context of this name, points to class instance + setattr(context, name, c) + + def add(self, device, name): + """Add a device class instance to the registry, with a friendly name""" + self.store[name] = device + + def get(self, name): # -> Device + """Get the description for a device class from the store, and construct a class instance""" + c = self.store[name] + + if self.fsk_router != None: + if c.can_receive(): + if isinstance(c, Devices.MiHomeDevice): + ##print("Adding rx route for receive enabled device %s" % c) + address = (c.manufacturer_id, c.product_id, c.device_id) + self.fsk_router.add(address, c) + return c + + def rename(self, old_name, new_name): + """Rename a device in the registry""" + c = self.store[old_name] # get the class instance + self.delete(old_name) # remove from memory and from any disk version + self.add(c, new_name) # Add the same class back, but with the new name + #Note: If rx routes are defined, they will still be correct, + # because they wire directly to the device class instance + + def delete(self, name): + """Delete the named class instance""" + del self.store[name] + + def list(self): + """List the registry in a vaguely printable format, mostly for debug""" + print("REGISTERED DEVICES:") + for k in self.store.keys(): + print(" %s -> %s" % (k, self.store[k])) + + def size(self): + """How many entries are there in the registry?""" + return self.store.size() + + def devices(self): + """A generator/iterator that can be used to get a list of device instances""" + + # Python2 and Python3 safe + for k in self.store.keys(): + device = self.store[k] + yield device + + # first get a list of all devices, in case the registry changes while iterating + ##devices = self.store.keys() + + # now 'generate' one per call + ##i = 0 + ##while i < len(devices): + ## k = devices[i] + ## device = self.store[k] + ## yield device + ## i += 1 + + def names(self): + """A generator/iterator that can be used to get a list of device names""" + # first get a list of all devices, in case the registry changes while iterating + devices = self.store.keys() + + # now 'generate' one per call + i = 0 + while i < len(devices): + k = devices[i] + yield k + i += 1 -def size(): - return len(directory) +#----- DISCOVERY AND LEARNING ------------------------------------------------- +#5. LEARN/DISCOVER: To be able to instigate and manage learn mode from within an app +# +# a. To send specific commands to green button devices so they can +# learn the pattern +# ? broadcast specific (house_code, index) repeatedly +# ? user assisted start/stop + +# b. To sniff for any messages from MiHome devices and capture them +# for later analysis and turning into device objects +# ? either as a special receive-only learn mode +# ? or as part of normal receive operation through routing unknown device id's +# ? need a way to take a device id and consult active directory list, +# and route to the correct class instance - a router for incoming messages + +# This means we need an incoming message 'router' with a message pump +# that the app can call - whenever it is in receive, does a peek and +# if there is a message, it knows what modulaton scheme is in use +# so can route the message with (modulation, payload) + +# c. To process MiHome join requests, and send MiHome join acks +# ? this would be routed by address to the device class + +# This also needs the message pump -def get_sensorids(): - return directory.keys() +#----- MESSAGE ROUTER --------------------------------------------------------- + +# a handler that is called whenever a message is received. +# routes it to the correct handling device class instance +# or instigates the unknown handler +# consults a RAM copy of part of the registry +# from mfrid,productid,sensorid -> handler + +# The RAM copy is a routing table +# it must be updated whenever a factory returns a device class instance. + +# Note, if you have a device class instance that is not registered, +# this means it cannot receive messages unless you pass them to it yourself. +# That's fine? + +# might be one for OOK devices, a different one for FSK devices +# as they have different keying rules. OOK receive will only probably +# occur from another raspberry pi, or from a hand controller or MiHome hub. +# But it is possible to OOK receive a payload, it only has a house address +# and 4 index bits in it and no data, but those are routeable. + +class Router(): + def __init__(self, name): + self.name = name # probably FSK or OOK + self.routes = {} # key(tuple of ids) -> value(device class instance) + self.unknown_cb = None + self.incoming_cb = None + + def add(self, address, instance): + """Add this device instance to the routing table""" + # When a message comes in for this address, it will be routed to its handle_message() method + # address might be a string, a number, a tuple, but probably always the same for any one router + self.routes[address] = instance + + def list(self): + print("ROUTES:") + for address in self.routes: + print(" %s->%s" % (str(address), str(self.routes[address]))) + + def incoming_message(self, address, message): + if self.incoming_cb != None: + self.incoming_cb(address, message) + + ##print("router.incoming addr=%s" % str(address)) + ##print("routes:%s" % str(self.routes)) + + if address in self.routes: + ci = self.routes[address] + ci.incoming_message(message) + + else: # address has no route + print("No route to an object, for device:%s" % str(address)) + #TODO: Could consult registry and squash if registry knows it + self.handle_unknown(address, message) + + def when_incoming(self, callback): + self.incoming_cb = callback + + def when_unknown(self, callback): + """Register a callback for unknown messages""" + #NOTE: this is the main hook point for auto discovery and registration + self.unknown_cb = callback + + def handle_unknown(self, address, message): + if self.unknown_cb != None: + self.unknown_cb(address, message) + else: + # Default action is just a debug message, and drop the message + print("Unknown address: %s" % str(address)) -def get_info(sensor_id): - return directory[sensor_id] +#---- DISCOVERY AGENT --------------------------------------------------------- +# +# Handles the discovery process when new devices appear and send reports. + +class Discovery(): + """A Discovery agent that just reports any unknown devices""" + def __init__(self, registry, router): + self.registry = registry + self.router = router + router.when_unknown(self.unknown_device) + + def unknown_device(self, address, message): + print("message from unknown device:%s" % str(address)) + # default action is to drop message + # override this method in sub classes if you want special processing + + def reject_device(self, address, message): + print("message rejected from:%s" % (str(address))) + # default action is to drop message + # override this method if you want special processing + + def accept_device(self, address, message, forward=True): + ##print("accept_device:%s" % str(address)) + # At moment, intentionally assume everything is mfrid=Energenie + product_id = address[1] + device_id = address[2] + ##print("**** wiring up registry and router for %s" % str(address)) + ci = Devices.DeviceFactory.get_device_from_id(product_id, device_id) + self.registry.add(ci, "auto_%s_%s" % (str(hex(product_id)), str(hex(device_id)))) + self.router.add(address, ci) + + # Finally, forward the first message to the new device class instance + if forward: + ##print("**** routing first message to class instance") + ci.incoming_message(message) + + ##self.registry.list() + ##self.router.list() + return ci # The new device class instance that we created + + +class AutoDiscovery(Discovery): + """A discovery agent that auto adds unknown devices""" + def __init__(self, registry, router): + Discovery.__init__(self, registry, router) + + def unknown_device(self, address, message): + self.accept_device(address, message) + + +class ConfirmedDiscovery(Discovery): + """A discovery agent that asks the app before accepting/rejecting""" + def __init__(self, registry, router, ask): + Discovery.__init__(self, registry, router) + self.ask_fn = ask + + def unknown_device(self, address, message): + y = self.ask_fn(address, message) + if y: + self.accept_device(address, message) + else: + self.reject_device(address, message) + + +class JoinAutoDiscovery(Discovery): + """A discovery agent that looks for join requests, and auto adds""" + def __init__(self, registry, router): + Discovery.__init__(self, registry, router) + + def unknown_device(self, address, message): + ##print("unknown device auto join %s" % str(address)) + + #TODO: need to make this work with correct meta methods + ##if not OpenThings.PARAM_JOIN in message: + try: + j = message[OpenThings.PARAM_JOIN] + except KeyError: + j = None + + if j == None: # not a join + Discovery.unknown_device(self, address, message) + else: # it is a join + # but don't forward the join request as it will be malformed with no value + ci = self.accept_device(address, message, forward=False) + ci.join_ack() # Ask new class instance to send a join_ack back to physical device + + +class JoinConfirmedDiscovery(Discovery): + """A discovery agent that looks for join requests, and auto adds""" + def __init__(self, registry, router, ask): + Discovery.__init__(self, registry, router) + self.ask_fn = ask + + def unknown_device(self, address, message): + print("**** unknown device confirmed join %s" % str(address)) + + #TODO: need to make this work with correct meta methods + ##if not OpenThings.PARAM_JOIN in message: + try: + j = message[OpenThings.PARAM_JOIN] + except KeyError: + j = None + + if j == None: # not a join + Discovery.unknown_device(self, address, message) + else: # it is a join + y = self.ask_fn(address, message) + if y: + # but don't forward the join request as it will be malformed with no value + ci = self.accept_device(address, message, forward=False) + ci.join_ack() # Ask new class instance to send a join_ack back to physical device + else: + self.reject_device(address, message) + # END diff --git a/src/energenie/Registry_test.py b/src/energenie/Registry_test.py new file mode 100644 index 0000000..a2d60ae --- /dev/null +++ b/src/energenie/Registry_test.py @@ -0,0 +1,298 @@ +# Registry_Test.py 21/05/2016 D.J.Whale +# +# Test harness for the Registry +# includes: DeviceRegistry, Router, Discovery + +import unittest +from Registry import * +import radio +from lifecycle import * + +radio.DEBUG=True + +REGISTRY_KVS = "registry.kvs" + + +#----- FILE HELPERS ----------------------------------------------------------- +# +#TODO: This is repeated in KVS_test.py + +def remove_file(filename): + import os + try: + os.unlink(filename) + except OSError: + pass # ok if it does not already exist + + +def show_file(filename): + """Show the contents of a file on screen""" + with open(filename) as f: + for l in f.readlines(): + l = l.strip() # remove nl + print(l) + +def create_file(filename): + with open(filename, "w"): + pass + + +#----- TEST REGISTRY ---------------------------------------------------------- + +class TestRegistry(unittest.TestCase): + + @test_1 + def test_create(self): + """Make a registry file by calling methods, and see that the file is the correct format""" + remove_file(REGISTRY_KVS) + create_file(REGISTRY_KVS) + registry = DeviceRegistry(REGISTRY_KVS) + + # add some devices to the registry, it should auto update the file + tv = Devices.MIHO005(device_id=0x68b) + fan = Devices.ENER002(device_id=(0xC8C8C, 1)) + registry.add(tv, "tv") + registry.add(fan, "fan") + + # see what the file looks like + show_file(registry.DEFAULT_FILENAME) + + + @test_1 + def test_load(self): + """Load back a persisted registry and create objects from them, in the registry""" + + # create a registry file + remove_file(REGISTRY_KVS) + create_file(REGISTRY_KVS) + registry = DeviceRegistry(REGISTRY_KVS) + registry.add(Devices.MIHO005(device_id=0x68b), "tv") + registry.add(Devices.ENER002(device_id=(0xC8C8C, 1)), "fan") + registry.list() + + + # clear the in memory registry + registry = None + + # create and load from file + registry = DeviceRegistry() + fsk_router = Router("fsk") + registry.set_fsk_router(fsk_router) + registry.load_from(REGISTRY_KVS) + + # dump the registry state + registry.list() + + # get device intances, this will cause receive routes to be knitted up + tv = registry.get("tv") + fan = registry.get("fan") + registry.fsk_router.list() + + + @test_1 + def test_load_into(self): + + # create an in memory registry + registry = DeviceRegistry() + registry.add(Devices.MIHO005(device_id=0x68b), "tv") + registry.add(Devices.ENER002(device_id=(0xC8C8C, 1)), "fan") + + class MyContext():pass + context = MyContext() + + registry.load_into(context) + print(context.tv) + print(context.fan) + + +#----- TEST ROUTER ------------------------------------------------------------ + +class TestRouter(unittest.TestCase): + def setUp(self): + # seed the registry + registry = DeviceRegistry() + registry.add(Devices.MIHO005(device_id=0x68b), "tv") + registry.add(Devices.ENER002(device_id=(0xC8C8C, 1)), "fan") + + # test the auto create mechanism + registry.load_into(self) + + @test_1 + def test_capabilities(self): + print("tv switch:%s" % self.tv.has_switch()) + print("tv send:%s" % self.tv.can_send()) + print("tv receive:%s" % self.tv.can_receive()) + + print("fan switch:%s" % self.fan.has_switch()) + print("fan send:%s" % self.fan.can_send()) + print("fan receive:%s" % self.fan.can_receive()) + + @test_1 + def test_ook_tx(self): + """Test the transmit pipeline""" + + self.fan.turn_on() + self.fan.turn_off() + + @test_1 + def test_fsk_tx(self): + """Test the transmit pipeline for MiHome FSK devices""" + + self.tv.turn_on() + self.tv.turn_off() + + @test_1 + def test_fsk_rx(self): + """Test the receive pipeline for FSK MiHome adaptor""" + + #synthesise receiving a report message + #push it down the receive pipeline + #radio.receive() + # ->OpenThingsAirInterface.incoming + # ->OpenThings.decrypt + # ->OpenThings.decode + # ->OpenThingsAirInterface->route + # ->MIHO005.incoming_message() + # + #it should update voltage, power etc + # poor mans incoming synthetic message + + + report = OpenThings.Message(Devices.MIHO005_REPORT) + report.set(recs_VOLTAGE_value=240, + recs_CURRENT_value=2, + recs_FREQUENCY_value=50, + recs_REAL_POWER_value=100, + recs_REACTIVE_POWER_value=0, + recs_APPARENT_POWER_value=100) + self.tv.incoming_message(report) + + # get readings from device + voltage = self.tv.get_voltage() + frequency = self.tv.get_frequency() + power = self.tv.get_real_power() + switch = self.tv.is_on() + + print("voltage %f" % voltage) + print("frequency %f" % frequency) + print("real power %f" % power) + print("switch %s" % switch) + + +#----- TEST DISCOVERY --------------------------------------------------------- + +UNKNOWN_SENSOR_ID = 0x111 + +class TestDiscovery(unittest.TestCase): + def setUp(self): + # build a synthetic message + self.msg = OpenThings.Message(Devices.MIHO005_REPORT) + self.msg[OpenThings.PARAM_VOLTAGE]["value"] = 240 + + self.fsk_router = Router("fsk") + + #OOK receive not yet written + #It will be used to be able to learn codes from Energenie legacy hand remotes + ##ook_router = Registry.Router("ook") + + self.registry = DeviceRegistry() + self.registry.set_fsk_router(self.fsk_router) + + @test_1 + def test_discovery_none(self): + self.fsk_router.when_unknown(None) # Discovery NONE + + + # Poke synthetic unknown into the router and let it route to unknown handler + self.msg.set(header_sensorid=UNKNOWN_SENSOR_ID) + self.fsk_router.incoming_message( + (Devices.MFRID_ENERGENIE, Devices.PRODUCTID_MIHO005, UNKNOWN_SENSOR_ID), self.msg) + + # expect unknown handler to fire + + @test_1 + def test_discovery_auto(self): + d = AutoDiscovery(self.registry, self.fsk_router) # Discovery AUTO + + # Poke synthetic unknown into the router and let it route to unknown handler + self.msg.set(header_sensorid=UNKNOWN_SENSOR_ID) + self.fsk_router.incoming_message( + (Devices.MFRID_ENERGENIE, Devices.PRODUCTID_MIHO005, UNKNOWN_SENSOR_ID), self.msg) + + # expect auto accept logic to fire + self.registry.list() + self.fsk_router.list() + + @test_1 + def test_discovery_ask(self): + def yes(a,b): return True + def no(a,b): return False + + d = ConfirmedDiscovery(self.registry, self.fsk_router, no) # Discovery ASK(NO) + + + # Poke synthetic unknown into the router and let it route to unknown handler + self.msg.set(header_sensorid=UNKNOWN_SENSOR_ID) + self.fsk_router.incoming_message( + (Devices.MFRID_ENERGENIE, Devices.PRODUCTID_MIHO005, UNKNOWN_SENSOR_ID), self.msg) + + # expect a reject + + d = ConfirmedDiscovery(self.registry, self.fsk_router, yes) # Discovery ASK(YES) + + # Poke synthetic unknown into the router and let it route to unknown handler + self.msg.set(header_sensorid=UNKNOWN_SENSOR_ID) + self.fsk_router.incoming_message( + (Devices.MFRID_ENERGENIE, Devices.PRODUCTID_MIHO005, UNKNOWN_SENSOR_ID), self.msg) + + # expect a accept + self.registry.list() + self.fsk_router.list() + + @test_1 + def test_discovery_autojoin(self): + d = JoinAutoDiscovery(self.registry, self.fsk_router) # Discovery AUTO JOIN + + # Poke synthetic unknown JOIN into the router and let it route to unknown handler + msg = Devices.MIHO005.get_join_req(UNKNOWN_SENSOR_ID) + + self.fsk_router.incoming_message( + (Devices.MFRID_ENERGENIE, Devices.PRODUCTID_MIHO005, UNKNOWN_SENSOR_ID), msg) + + # expect auto accept and join_ack logic to fire + self.registry.list() + self.fsk_router.list() + + @test_1 + def test_discovery_askjoin(self): + def no(a,b): return False + def yes(a,b): return True + + d = JoinConfirmedDiscovery(self.registry, self.fsk_router, no) # Discovery ASK JOIN(NO) + + # Poke synthetic unknown JOIN into the router and let it route to unknown handler + msg = Devices.MIHO005.get_join_req(UNKNOWN_SENSOR_ID) + self.fsk_router.incoming_message( + (Devices.MFRID_ENERGENIE, Devices.PRODUCTID_MIHO005, UNKNOWN_SENSOR_ID), msg) + + # expect reject + self.registry.list() + self.fsk_router.list() + + d = JoinConfirmedDiscovery(self.registry, self.fsk_router, yes) # Discovery ASK JOIN(YES) + + self.fsk_router.incoming_message( + (Devices.MFRID_ENERGENIE, Devices.PRODUCTID_MIHO005, UNKNOWN_SENSOR_ID), msg) + + # expect auto accept and join_ack logic to fire + self.registry.list() + self.fsk_router.list() + + +if __name__ == "__main__": + import OpenThings + OpenThings.init(Devices.CRYPT_PID) + + unittest.main() + +# END \ No newline at end of file diff --git a/src/energenie/TwoBit.py b/src/energenie/TwoBit.py new file mode 100644 index 0000000..01a0c1c --- /dev/null +++ b/src/energenie/TwoBit.py @@ -0,0 +1,200 @@ +# encoder.py 27/03/2016 D.J.Whale +# +# payload encoder for use with OOK payloads + +ALL_SOCKETS = 0 + +# The preamble is now stored in the payload, +# this is more predictable than using the radio sync feature +PREAMBLE = [0x80, 0x00, 0x00, 0x00] + +# This generates a 20*4 bit address i.e. 10 bytes +# The number generated is always the same +# This is the random 'Energenie address prefix' +# The switch number is encoded in the payload +# 0000 00BA gets encoded as: +# 128 64 32 16 8 4 2 1 +# 1 B B 0 1 A A 0 +#payload = [] +#for i in range(10): +# j = i + 5 +# payload.append(8 + (j&1) * 6 + 128 + (j&2) * 48) +#dumpPayloadAsHex(payload) + +#this is just a fixed address generator, from the C code +#payload = [] +#for i in range(10): +# j = i + 5 +# payload.append(8 + (j&1) * 6 + 128 + (j&2) * 48) +#dumpPayloadAsHex(payload) +# binary = 0110 1100 0110 1100 0110 +# hex = 6 C 6 C 6 + +DEFAULT_ADDR = 0x6C6C6 + +# 5 6 7 8 9 10 11 12 13 14 +# 1(01) 1(10) 1(11) 0(00) 0(01) 0(10) 0(11) 1(00) 1(01) 1(10) +DEFAULT_ADDR_ENC = [0x8e, 0xe8, 0xee, 0x88, 0x8e, 0xe8, 0xee, 0x88, 0x8e, 0xe8] + +# D0=high, D1=high, D2-high, D3=high (S1 on) sent as:(D0D1D2D3) +# 128 64 32 16 8 4 2 1 128 64 32 16 8 4 2 1 +# 1 B B 0 1 A A 0 1 B B 0 1 A A 0 +# 1 1 1 0 1 1 1 0 1 1 1 0 1 1 1 0 + +SW1_ON_ENC = [0xEE, 0xEE] # 1111 sent as 1111 + +# D0=high, D1=high, D2=high, D3=low (S1 off) +# 128 64 32 16 8 4 2 1 128 64 32 16 8 4 2 1 +# 1 B B 0 1 A A 0 1 B B 0 1 A A 0 +# 1 1 1 0 1 1 1 0 1 1 1 0 1 0 0 0 + +SW1_OFF_ENC = [0xEE, 0xE8] # 1110 sent as 0111 + + +def ashex(payload): # -> str with hexascii bytes + line = "" + for b in payload: + line += str(hex(b)) + " " + return line + + +def encode_relay_message(relayState=False): # -> list of numbers + """Temporary test code to prove we can turn the relay on or off""" + + payload = PREAMBLE #TODO: + DEFAULT_ADDR_ENC ?? + + if relayState: # ON + payload += SW1_ON_ENC + + else: # OFF + payload += SW1_OFF_ENC + + return payload + + +def encode_test_message(pattern): #-> list of numbers + """build a test message for a D3D2D1D0 control patter""" + payload = PREAMBLE + DEFAULT_ADDR_ENC + pattern &= 0x0F + control = encode_bits(pattern, 4) + payload += control + return payload + + +def encode_switch_message(state, device_address=ALL_SOCKETS, house_address=None): # -> list of numbers + """Build a message to turn a switch on or off""" + ##print("build: state:%s, device:%d, house:%s" % (str(state), device_address, str(house_address))) + + if house_address == None: + house_address = DEFAULT_ADDR + + payload = [] + PREAMBLE + payload += encode_bits((house_address & 0x0F0000) >> 16, 4) + payload += encode_bits((house_address & 0x00FF00) >> 8, 8) + payload += encode_bits((house_address & 0x0000FF), 8) + + # Coded as per the (working) C code, as it is transmitted D0D1D2D3: + # D 0123 + # b 3210 + # 0000 UNUSED 0 + # 0001 UNUSED 1 + # 0010 socket 4 off 2 + # 0011 socket 4 on 3 + # 0100 UNUSED 4 + # 0101 UNUSED 5 + # 0110 socket 2 off 6 + # 0111 socket 2 on 7 + # 1000 UNUSED 8 + # 1001 UNUSED 9 + # 1010 socket 3 off A + # 1011 socket 3 on B + # 1100 all off C + # 1101 all on D + # 1110 socket 1 off E + # 1111 socket 1 on F + + if not state: # OFF + bits = 0x00 + else: # ON + bits = 0x01 + + if device_address == ALL_SOCKETS: + bits |= 0x0C # ALL + elif device_address == 1: + bits |= 0x0E + elif device_address == 2: + bits |= 0x06 + elif device_address == 3: + bits |= 0x0A + elif device_address == 4: + bits |= 0x02 + + payload += encode_bits(bits, 4) + ##print("encoded as:%s" % ashex(payload)) + return payload + + +def encode_bytes(data): # -> list of numbers + """Turn a list of bytes into a modulated pattern equivalent""" + ##print("modulate_bytes: %s" % ashex(data)) + payload = [] + for b in data: + payload += encode_bits(b, 8) + ##print(" returns: %s" % ashex(payload)) + return payload + + +ENCODER = [0x88, 0x8E, 0xE8, 0xEE] + +def encode_bits(data, number): # -> list of numbers + """Turn bits into n bytes of modulation patterns""" + # 0000 00BA gets encoded as: + # 128 64 32 16 8 4 2 1 + # 1 B B 0 1 A A 0 + # i.e. a 0 is a short pulse, a 1 is a long pulse + ##print("modulate_bits %s (%s)" % (ashex(data), str(number))) + + shift = number-2 + encoded = [] + for i in range(int(number/2)): + bits = (data >> shift) & 0x03 + ##print(" shift %d bits %d" % (shift, bits)) + encoded.append(ENCODER[bits]) + shift -= 2 + ##print(" returns:%s" % ashex(encoded)) + return encoded + + +def decode_switch_message(bytes): # -> (house_address, device_index, state) + pass #TODO + # house_address, device_index, state + + +def decode_command(bytes): #-> (device_index, state) + pass #TODO + # 0, False (all off) + # 0, True (all on) + # 1, False (1 off) + # 1, True (1 on) + # 2, False (2 off) + # 2, True (2 on) + # 3, False (3 off) + # 3, True (3 on) + # 4, False (4 off) + # 4, True (4 on) + # UNKNOWN (6 of the other patterns, that are not recognised) + + +def decode_bytes(bytes): # -> list of numbers, decoded bytes + pass #TODO + + +def decode_bits(bits, number): # -> list of bytes, decoded bits + # decode 'number' of bits held in 'bits' and return as a list of 1 or more bytes + # e.g. decode_bits(0xEE, 2) -> 0b00000011 + pass #TODO + + + +# END + diff --git a/src/energenie/TwoBit_test.py b/src/energenie/TwoBit_test.py new file mode 100644 index 0000000..9226864 --- /dev/null +++ b/src/energenie/TwoBit_test.py @@ -0,0 +1,47 @@ +# test_encoder.py 27/03/2016 D.J.Whale +# +# Test the OOK message encoder + +import unittest +import TwoBit + +def ashex(data): + if type(data) == list: + line = "" + for b in data: + line += str(hex(b)) + " " + return line + else: + return str(hex(data)) + + +#----- TEST APPLICATION ------------------------------------------------------- + +class TestTwoBit(unittest.TestCase): + ALL_ON = TwoBit.encode_switch_message(True) + ALL_OFF = TwoBit.encode_switch_message(False) + ONE_ON = TwoBit.encode_switch_message(True, device_address=1) + ONE_OFF = TwoBit.encode_switch_message(False, device_address=1) + TWO_ON = TwoBit.encode_switch_message(True, device_address=2) + TWO_OFF = TwoBit.encode_switch_message(False, device_address=2) + THREE_ON = TwoBit.encode_switch_message(True, device_address=3) + THREE_OFF = TwoBit.encode_switch_message(False, device_address=3) + FOUR_ON = TwoBit.encode_switch_message(True, device_address=4) + FOUR_OFF = TwoBit.encode_switch_message(False, device_address=4) + MYHOUSE_ALL_ON = TwoBit.encode_switch_message(True, house_address=0x12345) + + tests = [ALL_ON, ALL_OFF, ONE_ON, ONE_OFF, TWO_ON, TWO_OFF, THREE_ON, THREE_OFF, FOUR_ON, FOUR_OFF, MYHOUSE_ALL_ON] + + def test_all(self): + for t in self.tests: + print('') + print(ashex(t)) + + +if __name__ == "__main__": + unittest.main() + + +# END + + diff --git a/src/energenie/__init__.py b/src/energenie/__init__.py index e69de29..55267f5 100644 --- a/src/energenie/__init__.py +++ b/src/energenie/__init__.py @@ -0,0 +1,138 @@ +# energenie.py 24/05/2016 D.J.Whale +# +# Provides the app writer with a simple single module interface to everything. +# At the moment, this just hides the fact that the radio module needs to be initialised +# at the start and cleaned up at the end. +# +# Future versions of this *might* also start receive monitor or scheduler threads. + +import time +import os + +try: + # Python 2 + import radio + import Devices + import Registry + import OpenThings +except ImportError: + # Python 3 + from . import radio + from . import Devices + from . import Registry + from . import OpenThings + + +registry = None +fsk_router = None +ook_router = None + + +def init(): + """Start the Energenie system running""" + + global registry, fsk_router, ook_router + + radio.init() + OpenThings.init(Devices.CRYPT_PID) + + fsk_router = Registry.Router("fsk") + + #OOK receive not yet written + #It will be used to be able to learn codes from Energenie legacy hand remotes + ##ook_router = Registry.Router("ook") + + registry = Registry.DeviceRegistry() + registry.set_fsk_router(fsk_router) + ##registry.set_ook_router(ook_router + + if os.path.isfile(registry.DEFAULT_FILENAME): + registry.load_from(registry.DEFAULT_FILENAME) + print("loaded registry from file") + registry.list() + fsk_router.list() + + # Default discovery mode, unless changed by app + ##discovery_none() + ##discovery_auto() + ##discovery_ask(ask) + discovery_autojoin() + ##discovery_askjoin(ask) + + +def loop(receive_time=1): + """Handle receive processing""" + radio.receiver(fsk=True) + timeout = time.time() + receive_time + handled = False + + while True: + if radio.is_receive_waiting(): + payload = radio.receive_cbp() + now = time.time() + try: + msg = OpenThings.decode(payload, receive_timestamp=now) + hdr = msg["header"] + mfr_id = hdr["mfrid"] + product_id = hdr["productid"] + device_id = hdr["sensorid"] + address = (mfr_id, product_id, device_id) + + registry.fsk_router.incoming_message(address, msg) + handled = True + except OpenThings.OpenThingsException: + print("Can't decode payload:%s" % payload) + + now = time.time() + if now > timeout: break + + return handled + + +def finished(): + """Cleanly close the Energenie system when finished""" + radio.finished() + + + +def discovery_none(): + fsk_router.when_unknown(None) + + +def discovery_auto(): + d = Registry.AutoDiscovery(registry, fsk_router) + ##print("Using auto discovery") + + +def discovery_ask(ask_fn): + d = Registry.ConfirmedDiscovery(registry, fsk_router, ask_fn) + ##print("using confirmed discovery") + + +def discovery_autojoin(): + d = Registry.JoinAutoDiscovery(registry, fsk_router) + ##print("using auto join discovery") + + +def discovery_askjoin(ask_fn): + d = Registry.JoinConfirmedDiscovery(registry, fsk_router, ask_fn) + ##print("using confirmed join discovery") + + +def ask(address, message): + MSG = "Do you want to register to device: %s? " % str(address) + try: + if message != None: + print(message) + y = raw_input(MSG) + + except AttributeError: + y = input(MSG) + + if y == "": return True + y = y.upper() + if y in ['Y', 'YES']: return True + return False + + +# END diff --git a/src/energenie/crypto.py b/src/energenie/crypto.py index 7156718..8be85d2 100644 --- a/src/energenie/crypto.py +++ b/src/energenie/crypto.py @@ -3,16 +3,8 @@ # Crypto engine for OpenThings, including crc calculation - ran = None -#static uint16_t ran; - -#void seed(uint8_t pid, uint16_t pip) -#{ -# ran = ((((uint16_t) pid) << 8) ^ pip); -#} - def init(pid, pip): """Initialise the crypto engine state variables""" @@ -20,17 +12,6 @@ ran = (((pid&0xFF)<<8) ^ pip) & 0xFFFF # maintain U16 -#uint8_t crypt(uint8_t dat) -#{ -# unsigned char i; //(u8) - -# for (i = 0; i < 5; ++i) -# { -# ran = (ran & 1) ? ((ran >> 1) ^ 62965U) : (ran >> 1); -# } -# return (uint8_t)(ran ^ dat ^ 90U); -#} - def cryptByte(data): """crypt a byte of data and update the crypto engine state variable""" global ran diff --git a/src/energenie/crypto_test.py b/src/energenie/crypto_test.py new file mode 100644 index 0000000..181a015 --- /dev/null +++ b/src/energenie/crypto_test.py @@ -0,0 +1,8 @@ +# crypto_test.py 21/05/2016 D.J.Whale +# +# Placeholder for test harness for crypto.py + +#TODO: + +print("no tests defined") +# END diff --git a/src/energenie/encoder.py b/src/energenie/encoder.py deleted file mode 100644 index 4cc0cec..0000000 --- a/src/energenie/encoder.py +++ /dev/null @@ -1,169 +0,0 @@ -# encoder.py 27/03/2016 D.J.Whale -# -# payload encoder for use with OOK payloads - -ALL_SOCKETS = 0 - -# The preamble is now stored in the payload, -# this is more predictable than using the radio sync feature -PREAMBLE = [0x80, 0x00, 0x00, 0x00] - -# This generates a 20*4 bit address i.e. 10 bytes -# The number generated is always the same -# This is the random 'Energenie address prefix' -# The switch number is encoded in the payload -# 0000 00BA gets encoded as: -# 128 64 32 16 8 4 2 1 -# 1 B B 0 1 A A 0 -#payload = [] -#for i in range(10): -# j = i + 5 -# payload.append(8 + (j&1) * 6 + 128 + (j&2) * 48) -#dumpPayloadAsHex(payload) - -#this is just a fixed address generator, from the C code -#payload = [] -#for i in range(10): -# j = i + 5 -# payload.append(8 + (j&1) * 6 + 128 + (j&2) * 48) -#dumpPayloadAsHex(payload) -# binary = 0110 1100 0110 1100 0110 -# hex = 6 C 6 C 6 - -DEFAULT_ADDR = 0x6C6C6 - -# 5 6 7 8 9 10 11 12 13 14 -# 1(01) 1(10) 1(11) 0(00) 0(01) 0(10) 0(11) 1(00) 1(01) 1(10) -DEFAULT_ADDR_ENC = [0x8e, 0xe8, 0xee, 0x88, 0x8e, 0xe8, 0xee, 0x88, 0x8e, 0xe8] - -# D0=high, D1=high, D2-high, D3=high (S1 on) sent as:(D0D1D2D3) -# 128 64 32 16 8 4 2 1 128 64 32 16 8 4 2 1 -# 1 B B 0 1 A A 0 1 B B 0 1 A A 0 -# 1 1 1 0 1 1 1 0 1 1 1 0 1 1 1 0 - -SW1_ON_ENC = [0xEE, 0xEE] # 1111 sent as 1111 - -# D0=high, D1=high, D2=high, D3=low (S1 off) -# 128 64 32 16 8 4 2 1 128 64 32 16 8 4 2 1 -# 1 B B 0 1 A A 0 1 B B 0 1 A A 0 -# 1 1 1 0 1 1 1 0 1 1 1 0 1 0 0 0 - -SW1_OFF_ENC = [0xEE, 0xE8] # 1110 sent as 0111 - - -def ashex(payload): - line = "" - for b in payload: - line += str(hex(b)) + " " - return line - - -def build_relay_msg(relayState=False): - """Temporary test code to prove we can turn the relay on or off""" - - payload = PREAMBLE - - if relayState: # ON - payload += SW1_ON_ENC - - else: # OFF - payload += SW1_OFF_ENC - - return payload - - -def build_test_message(pattern): - """build a test message for a D3D2D1D0 control patter""" - payload = PREAMBLE + DEFAULT_ADDR_ENC - pattern &= 0x0F - control = encode_bits(pattern, 4) - payload += control - return payload - - -def build_switch_msg(state, device_address=ALL_SOCKETS, house_address=None): - """Build a message to turn a switch on or off""" - #print("build: state:%s, device:%d, house:%s" % (str(state), device_address, str(house_address))) - - if house_address == None: - house_address = DEFAULT_ADDR - - payload = [] + PREAMBLE - payload += encode_bits((house_address & 0x0F0000) >> 16, 4) - payload += encode_bits((house_address & 0x00FF00) >> 8, 8) - payload += encode_bits((house_address & 0x0000FF), 8) - - # Coded as per the (working) C code, as it is transmitted D0D1D2D3: - # D 0123 - # b 3210 - # 0000 UNUSED 0 - # 0001 UNUSED 1 - # 0010 socket 4 off 2 - # 0011 socket 4 on 3 - # 0100 UNUSED 4 - # 0101 UNUSED 5 - # 0110 socket 2 off 6 - # 0111 socket 2 on 7 - # 1000 UNUSED 8 - # 1001 UNUSED 9 - # 1010 socket 3 off A - # 1011 socket 3 on B - # 1100 all off C - # 1101 all on D - # 1110 socket 1 off E - # 1111 socket 1 on F - - if not state: # OFF - bits = 0x00 - else: # ON - bits = 0x01 - - if device_address == ALL_SOCKETS: - bits |= 0x0C # ALL - elif device_address == 1: - bits |= 0x0E - elif device_address == 2: - bits |= 0x06 - elif device_address == 3: - bits |= 0x0A - elif device_address == 4: - bits |= 0x02 - - payload += encode_bits(bits, 4) - #print("encoded as:%s" % ashex(payload)) - return payload - - -def encode_bytes(data): - """Turn a list of bytes into a modulated pattern equivalent""" - #print("modulate_bytes: %s" % ashex(data)) - payload = [] - for b in data: - payload += encode_bits(b, 8) - #print(" returns: %s" % ashex(payload)) - return payload - - -ENCODER = [0x88, 0x8E, 0xE8, 0xEE] - -def encode_bits(data, number): - """Turn bits into n bytes of modulation patterns""" - # 0000 00BA gets encoded as: - # 128 64 32 16 8 4 2 1 - # 1 B B 0 1 A A 0 - # i.e. a 0 is a short pulse, a 1 is a long pulse - #print("modulate_bits %s (%s)" % (ashex(data), str(number))) - - shift = number-2 - encoded = [] - for i in range(int(number/2)): - bits = (data >> shift) & 0x03 - #print(" shift %d bits %d" % (shift, bits)) - encoded.append(ENCODER[bits]) - shift -= 2 - #print(" returns:%s" % ashex(encoded)) - return encoded - - -# END - diff --git a/src/energenie/encoder_test.py b/src/energenie/encoder_test.py deleted file mode 100644 index 2967bd6..0000000 --- a/src/energenie/encoder_test.py +++ /dev/null @@ -1,41 +0,0 @@ -# test_encoder.py 27/03/2016 D.J.Whale -# -# Test the OOK message encoder - -import encoder - -def ashex(data): - if type(data) == list: - line = "" - for b in data: - line += str(hex(b)) + " " - return line - else: - return str(hex(data)) - - -#----- TEST APPLICATION ------------------------------------------------------- - -print("*" * 80) - -ALL_ON = encoder.build_switch_msg(True) -ALL_OFF = encoder.build_switch_msg(False) -ONE_ON = encoder.build_switch_msg(True, device_address=1) -ONE_OFF = encoder.build_switch_msg(False, device_address=1) -TWO_ON = encoder.build_switch_msg(True, device_address=2) -TWO_OFF = encoder.build_switch_msg(False, device_address=2) -THREE_ON = encoder.build_switch_msg(True, device_address=3) -THREE_OFF = encoder.build_switch_msg(False, device_address=3) -FOUR_ON = encoder.build_switch_msg(True, device_address=4) -FOUR_OFF = encoder.build_switch_msg(False, device_address=4) -MYHOUSE_ALL_ON = encoder.build_switch_msg(True, house_address=0x12345) - -tests = [ALL_ON, ALL_OFF, ONE_ON, ONE_OFF, TWO_ON, TWO_OFF, THREE_ON, THREE_OFF, FOUR_ON, FOUR_OFF, MYHOUSE_ALL_ON] - -for t in tests: - print('') - print(ashex(t)) - -# END - - diff --git a/src/energenie/lifecycle.py b/src/energenie/lifecycle.py new file mode 100644 index 0000000..c3c7d5a --- /dev/null +++ b/src/energenie/lifecycle.py @@ -0,0 +1,68 @@ +# lifecycle.py 21/05/2016 D.J.Whale +# +# Coding lifecycle method decorators. + +def unimplemented(m): + ##print("warning: unimplemented method %s" % str(m)) + def inner(*args, **kwargs): + raise RuntimeError("Method is unimplemented: %s" % str(m)) + return inner + + +def disabled(m): + """Load-time waring about disabled function""" + print("warning: method is disabled:%s" % m) + def nothing(*args, **kwargs): + print("warning: Calling disabled method %s does nothing" % str(m)) + return nothing + + +def untested(m): + print("warning: untested method %s" % str(m)) + return m + + +def log_method(m): + def inner(*args, **kwargs): + print("CALL %s with: %s %s" % (m, args, kwargs)) + r = m(*args, **kwargs) + print("RETURN %s with: %s" % (m, r)) + return r + return inner + + +def deprecated(m): + print("warning: deprecated method %s" % str(m)) + return m + + +def test_0(m): + ##print("test disabled:%s" % m) + ##def run(*args, **kwargs): + ## print("running:%s" % m) + ## r = m(*args, **kwargs) + ## print("finished:%s" % m) + ## return r + + def nothing(*args, **kwargs): + print("test disabled:%s" % m) + return None + + return nothing # DISABLE + ##return run # ENABLE ALL + + +def test_1(m): + def run(*args, **kwargs): + print("running:%s" % m) + r = m(*args, **kwargs) + print("finished:%s" % m) + return r + + return run + +# END + + + + diff --git a/src/energenie/radio.py b/src/energenie/radio.py index 6b6f294..56e08cb 100644 --- a/src/energenie/radio.py +++ b/src/energenie/radio.py @@ -15,6 +15,9 @@ #TODO: Should really add parameter validation here, so that C code doesn't have to. #although it will be faster in C (C could be made optional, like an assert?) +#TODO: Would like to add RSSI measurements and reporting to the metadata that +#comes back with received packets. + LIBNAME = "drv/radio_rpi.so" ##LIBNAME = "drv/radio_mac.so" # testing @@ -23,6 +26,8 @@ from os import path mydir = path.dirname(path.abspath(__file__)) +DEBUG = False + libradio = ctypes.cdll.LoadLibrary(mydir + "/" + LIBNAME) radio_init_fn = libradio["radio_init"] radio_reset_fn = libradio["radio_reset"] @@ -46,7 +51,7 @@ MAX_RX_SIZE = 66 -#TODO RADIO_RESULT_XX +#TODO: RADIO_RESULT_XX def trace(msg): print(str(msg)) @@ -59,30 +64,6 @@ return line -def unimplemented(m): - print("warning: method is not implemented:%s" % m) - return m - - -def deprecated(m): - """Load-time warning about deprecated method""" - print("warning: method is deprecated:%s" % m) - return m - - -def untested(m): - """Load-time warning about untested function""" - print("warning: method is untested:%s" % m) - return m - - -def disabled(m): - """Load-time waring about disabled function""" - print("warning: method is disabled:%s" % m) - def nothing(*args, **kwargs):pass - return nothing - - def init(): """Initialise the module ready for use""" #extern void radio_init(void); @@ -129,6 +110,13 @@ #Note, this optionally does a mode change before and after #extern void radio_transmit(uint8_t* payload, uint8_t len, uint8_t repeats); + if DEBUG: + print("***TX %s" % payload) + import OpenThings + if payload[0] < 20: # crude way to reject ook messages + print("PAYLOAD: %s" % OpenThings.decode(payload)) + # remember that the sensorId is encrypted too + framelen = len(payload) if framelen < 1 or framelen > 255: raise ValueError("frame len must be 1..255") @@ -232,28 +220,29 @@ return rxlist # Python len(rxlist) tells us how many bytes including length byte if present -@untested -def receive_len(size): - """Receive a fixed payload size""" - - bufsize = size - - Buffer = ctypes.c_ubyte * bufsize - rxbuf = Buffer() - buflen = ctypes.c_ubyte(bufsize) - #RADIO_RESULT radio_get_payload_len(uint8_t* buf, uint8_t buflen) - - result = radio_get_payload_len_fn(rxbuf, buflen) - - if result != 0: # RADIO_RESULT_OK - raise RuntimeError("Receive failed, error code %s" % hex(result)) - - # turn buffer into a list of bytes, using 'size' as the counter - rxlist = [] - for i in range(size): - rxlist.append(rxbuf[i]) - - return rxlist # Python len(rxlist) tells us how many bytes including length byte if present +#TODO: Placeholder for when we do OOK receive +##@untested +##def receive_len(size): +## """Receive a fixed payload size""" +## +## bufsize = size +## +## Buffer = ctypes.c_ubyte * bufsize +## rxbuf = Buffer() +## buflen = ctypes.c_ubyte(bufsize) +## #RADIO_RESULT radio_get_payload_len(uint8_t* buf, uint8_t buflen) +## +## result = radio_get_payload_len_fn(rxbuf, buflen) +## +## if result != 0: # RADIO_RESULT_OK +## raise RuntimeError("Receive failed, error code %s" % hex(result)) +## +## # turn buffer into a list of bytes, using 'size' as the counter +## rxlist = [] +## for i in range(size): +## rxlist.append(rxbuf[i]) +## +## return rxlist # Python len(rxlist) tells us how many bytes including length byte if present def standby(): diff --git a/src/energenie/radio_test.py b/src/energenie/radio_test.py index b02f117..b2e0cdc 100644 --- a/src/energenie/radio_test.py +++ b/src/energenie/radio_test.py @@ -12,12 +12,12 @@ # 4800bps*8*16=26ms per payload # 75 payloads is 2 seconds # 255 payloads is 6.8 seconds -TIMES = 75 +TIMES = 40 DELAY = 0.5 # The 'radio' module knows nothing about the Energenie (HS1527) bit encoding, # so this test code manually encodes the bits. -# For the full Python stack, there is an encoder module that can generate +# For the full Python stack, there is a TwoBit module that can generate # specific payloads. Repeats are done in radio_transmitter. # The HRF preamble feature is no longer used, it's more predictable to # put the preamble in the payload. diff --git a/src/energenie/registry.kvs b/src/energenie/registry.kvs new file mode 100644 index 0000000..5173a2f --- /dev/null +++ b/src/energenie/registry.kvs @@ -0,0 +1,8 @@ +ADD tv +type=MIHO005 +device_id=1675 + +ADD fan +type=ENER002 +device_id=[822412, 1] + diff --git a/src/games_console_minder.py b/src/games_console_minder.py new file mode 100644 index 0000000..dabbb71 --- /dev/null +++ b/src/games_console_minder.py @@ -0,0 +1,16 @@ +# games_console_minder.py 28/05/2016 D.J.Whale +# +# A simple demo showing how to turn the games console off after a timeout limit + +# REQUIREMENTS +# - senses when the games console is turned on via energy usage monitor and/or switch status +# - starts a cumulative timer of game time used +# - when a limit is reached, sound an alarm by playing a warning sound +# - when 1 minute from turn off, sound a very anoying warning sound +# - turn the plug off when alarm time reached +# - persist game used time to a file for recall later +# - allow limits on total time per day, and total time in one sitting +# - simple status messages on the screen + +# END + diff --git a/src/legacy.py b/src/legacy.py deleted file mode 100644 index 4bf3ab7..0000000 --- a/src/legacy.py +++ /dev/null @@ -1,152 +0,0 @@ -# legacy.py 17/03/2016 D.J.Whale -# -# Note: This is a test harness only, to prove that the underlying OOK -# radio support for legacy plugs is working. -# Please don't use this as a basis for building your applications from. -# Another higher level device API will be designed once this has been -# completely verified. - -import time - -from energenie import encoder, radio - -# How many times to send messages in the driver fast loop -# Present version of driver limits to 15 -# but this restriction will be lifted soon -# 4800bps, burst transmit time at 15 repeats is 400mS -# 1 payload takes 26ms -# 75 payloads takes 2s -INNER_TIMES = 16 - -# how many times to send messages in the API slow loop -# this is slower than using the driver, and will introduce tiny -# inter-burst delays -OUTER_TIMES = 1 - -# delay in seconds between each application switch message -APP_DELAY = 1 - -try: - readin = raw_input # python 2 -except NameError: - readin = input # python 3 - - -#----- TEST APPLICATION ------------------------------------------------------- - -# Prebuild all possible message up front, to make switching code faster - -HOUSE_ADDRESS = None # Use default energenie quasi-random address 0x6C6C6 -##HOUSE_ADDRESS = 0xA0170 # Captured address of David's RF hand controller - -ALL_ON = encoder.build_switch_msg(True, house_address=HOUSE_ADDRESS) -ONE_ON = encoder.build_switch_msg(True, device_address=1, house_address=HOUSE_ADDRESS) -TWO_ON = encoder.build_switch_msg(True, device_address=2, house_address=HOUSE_ADDRESS) -THREE_ON = encoder.build_switch_msg(True, device_address=3, house_address=HOUSE_ADDRESS) -FOUR_ON = encoder.build_switch_msg(True, device_address=4, house_address=HOUSE_ADDRESS) -ON_MSGS = [ALL_ON, ONE_ON, TWO_ON, THREE_ON, FOUR_ON] - -ALL_OFF = encoder.build_switch_msg(False, house_address=HOUSE_ADDRESS) -ONE_OFF = encoder.build_switch_msg(False, device_address=1, house_address=HOUSE_ADDRESS) -TWO_OFF = encoder.build_switch_msg(False, device_address=2, house_address=HOUSE_ADDRESS) -THREE_OFF = encoder.build_switch_msg(False, device_address=3, house_address=HOUSE_ADDRESS) -FOUR_OFF = encoder.build_switch_msg(False, device_address=4, house_address=HOUSE_ADDRESS) -OFF_MSGS = [ALL_OFF, ONE_OFF, TWO_OFF, THREE_OFF, FOUR_OFF] - - -def get_yes_no(): - """Get a simple yes or no answer""" - answer = readin() - if answer.upper() in ['Y', 'YES']: - return True - return False - - -def legacy_learn_mode(): - """Give the user a chance to learn any switches""" - print("Do you want to program any switches?") - y = get_yes_no() - if not y: - return - - for switch_no in range(1,5): - print("Learn switch %d?" % switch_no) - y = get_yes_no() - if y: - print("Press the LEARN button on any switch %d for 5 secs until LED flashes" % switch_no) - readin("press ENTER when LED is flashing") - - print("ON") - radio.transmit(ON_MSGS[switch_no], OUTER_TIMES, INNER_TIMES) - time.sleep(APP_DELAY) - - print("Device should now be programmed") - - print("Testing....") - for i in range(4): - time.sleep(APP_DELAY) - print("OFF") - radio.transmit(OFF_MSGS[switch_no], OUTER_TIMES, INNER_TIMES) - time.sleep(APP_DELAY) - print("ON") - radio.transmit(ON_MSGS[switch_no], OUTER_TIMES, INNER_TIMES) - print("Test completed") - - -def legacy_switch_loop(): - """Turn all switches on or off every few seconds""" - - while True: - for switch_no in range(5): - # switch_no 0 is ALL, then 1=1, 2=2, 3=3, 4=4 - # ON - print("switch %d ON" % switch_no) - radio.transmit(ON_MSGS[switch_no], OUTER_TIMES, INNER_TIMES) - time.sleep(APP_DELAY) - - # OFF - print("switch %d OFF" % switch_no) - radio.transmit(OFF_MSGS[switch_no], OUTER_TIMES, INNER_TIMES) - time.sleep(APP_DELAY) - - -def switch1_loop(): - """Repeatedly turn switch 1 ON then OFF""" - while True: - print("Switch 1 ON") - radio.transmit(ON_MSGS[1], OUTER_TIMES, INNER_TIMES) - time.sleep(APP_DELAY) - - print("Switch 1 OFF") - radio.transmit(OFF_MSGS[1], OUTER_TIMES, INNER_TIMES) - time.sleep(APP_DELAY) - - -def pattern_test(): - """Test all patterns""" - while True: - p = readin("number 0..F") - p = int(p, 16) - msg = encoder.build_test_message(p) - print("pattern %s payload %s" % (str(hex(p)), encoder.ashex(msg))) - radio.send_payload(msg, OUTER_TIMES, INNER_TIMES) - - -if __name__ == "__main__": - - print("starting legacy switch tester") - print("radio init") - radio.init() - print("radio as OOK") - radio.modulation(ook=True) - - try: - ##pattern_test() - legacy_learn_mode() - legacy_switch_loop() - ##switch1_loop() - finally: - radio.finished() - -# END - diff --git a/src/mihome_energy_monitor.py b/src/mihome_energy_monitor.py new file mode 100644 index 0000000..db913b0 --- /dev/null +++ b/src/mihome_energy_monitor.py @@ -0,0 +1,61 @@ +# mihome_energy_monitor.py 28/05/2016 D.J.Whale +# +# A simple demo of monitoring and logging energy usage of mihome devices +# +# Logs all messages to screen and to a file energenie.csv +# Any device that has a switch, it toggles it every 2 seconds. +# Any device that offers a power reading, it displays it. + +import energenie +import Logger +import time + +APP_DELAY = 2 +switch_state = False + +def energy_monitor_loop(): + global switch_state + + # Process any received messages from the real radio + energenie.loop() + + # For all devices in the registry, if they have a switch, toggle it + for d in energenie.registry.devices(): + if d.has_switch(): + d.set_switch(switch_state) + switch_state = not switch_state + + # For all devices in the registry, if they have a get_power(), call it + print("Checking device status") + for d in energenie.registry.devices(): + print(d) + try: + p = d.get_power() + print("Power: %s" % str(p)) + except: + pass # Ignore it if can't provide a power + + time.sleep(APP_DELAY) + + +if __name__ == "__main__": + + print("Starting energy monitor example") + + energenie.init() + + # provide a default incoming message handler, useful for logging every message + def incoming(address, message): + print("\nIncoming from %s" % str(address)) + Logger.logMessage(message) + energenie.fsk_router.when_incoming(incoming) + print("Logging to file:%s" % Logger.LOG_FILENAME) + + try: + while True: + energy_monitor_loop() + finally: + energenie.finished() + +# END + diff --git a/src/monitor.py b/src/monitor.py deleted file mode 100644 index dcaff84..0000000 --- a/src/monitor.py +++ /dev/null @@ -1,74 +0,0 @@ -# monitor.py 27/09/2015 D.J.Whale -# -# Monitor Energine MiHome plugs - -# Note, this is *only* a test program, to exercise the lower level code. -# Don't expect this to be a good starting point for an application. -# Consider waiting for me to finish developing the device object interface first. -# -# However, it will log all messages from MiHome monitor, adaptor plus and house monitor -# to a CSV log file, so could be the basis for a non-controlling energy logging app. - -from energenie import Registry, Devices, Messages, OpenThings, radio -import time -import Logger - -def warning(msg): - print("warning:%s" % str(msg)) - - -def trace(msg): - print("monitor:%s" % str(msg)) - - -#----- TEST APPLICATION ------------------------------------------------------- - -def monitor_loop(): - """Capture any incoming messages and log to CSV file""" - - radio.receiver() - - while True: - # See if there is a payload, and if there is, process it - if radio.is_receive_waiting(): - trace("receiving payload") - payload = radio.receive() - try: - decoded = OpenThings.decode(payload) - now = time.time() - except OpenThings.OpenThingsException as e: - warning("Can't decode payload:" + str(e)) - continue - - OpenThings.showMessage(decoded, timestamp=now) - # Any device that reports will be added to the non-persistent directory - Registry.update(decoded) - ##trace(decoded) - Logger.logMessage(decoded) - - # Process any JOIN messages by sending back a JOIN-ACK to turn the LED off - if len(decoded["recs"]) == 0: - # handle messages with zero recs in them silently - print("Empty record:%s" % decoded) - else: - # assume only 1 rec in a join, for now - if decoded["recs"][0]["paramid"] == OpenThings.PARAM_JOIN: - mfrid = OpenThings.getFromMessage(decoded, "header_mfrid") - productid = OpenThings.getFromMessage(decoded, "header_productid") - sensorid = OpenThings.getFromMessage(decoded, "header_sensorid") - Messages.send_join_ack(radio, mfrid, productid, sensorid) - - -if __name__ == "__main__": - - trace("starting monitor tester") - radio.init() - OpenThings.init(Devices.CRYPT_PID) - - try: - monitor_loop() - - finally: - radio.finished() - -# END diff --git a/src/registry.kvs b/src/registry.kvs new file mode 100644 index 0000000..bbacec5 --- /dev/null +++ b/src/registry.kvs @@ -0,0 +1,8 @@ +ADD fan +type=ENER002 +device_id=[0x6C6C6, 1] + +ADD tv +type=MIHO005 +device_id=1675 + diff --git a/src/setup_tool.py b/src/setup_tool.py new file mode 100644 index 0000000..5d3457f --- /dev/null +++ b/src/setup_tool.py @@ -0,0 +1,350 @@ +# setup_tool.py 28/05/2016 D.J.Whale +# +# A simple menu-driven setup tool for the Energenie Python library. +# +# Just be a simple menu system. +# This then means you don't have to have all this in the demo apps +# and the demo apps can just refer to object variables names +# from an assumed auto_create registry, that is built using this setup tool. + + +import time +import energenie +##from energenie.lifecycle import * + + +#===== GLOBALS ===== + +quit = False + + +#===== INPUT METHODS ========================================================== + +try: + readin = raw_input # Python 2 +except NameError: + readin = input # Python 3 + + +def get_house_code(): + """Get a house code or default to Energenie code""" + + while True: + try: + hc = readin("House code (ENTER for default)? ") + if hc == "": return None + + except KeyboardInterrupt: + return None # user abort + + try: + house_code = int(hc, 16) + return house_code + + except ValueError: + print("Must enter a number") + + +def get_device_index(): + """get switch index, default 1 (0,1,2,3,4)""" + + while True: + try: + di = readin("Device index 1..4 (ENTER for all)? ") + except KeyboardInterrupt: + return None # user abort + + if di == "": return 0 # ALL + try: + device_index = int(di) + return device_index + + except ValueError: + print("Must enter a number") + + +def show_registry(): + """Show the registry as a numbered list""" + + i=1 + names = [] + for name in energenie.registry.names(): + print("%d. %s %s" % (i, name, energenie.registry.get(name))) + names.append(name) + i += 1 + + return names + + +def get_device_name(): + """Give user a list of devices and choose one from the list""" + + names = show_registry() + + try: + while True: + i = readin("Which device %s to %s? " % (1, len(names))) + try: + device_index = int(i) + if device_index < 1 or device_index > len(names): + print("Choose a number between %s and %s" % (1, len(names))) + else: + break # got it + except ValueError: + print("Must enter a number") + + except KeyboardInterrupt: + return None # nothing chosen, user aborted + + name = names[device_index-1] + print("selected: %s" % name) + + return name + + +#===== ACTION ROUTINES ======================================================== + +def do_legacy_learn(): + """Repeatedly broadcast a legacy switch message, so you can learn a socket to the pattern""" + + # get device + house_code = get_house_code() + device_index = get_device_index() + device = energenie.Devices.ENER002((house_code, device_index)) + + # in a loop until Ctrl-C + print("Legacy learn broadcasting, Ctrl-C to stop") + try: + while True: + print("ON") + device.turn_on() + time.sleep(0.5) + + print("OFF") + device.turn_off() + time.sleep(0.5) + + except KeyboardInterrupt: + pass # user exit + + +def do_mihome_discovery(): + """Discover any mihome device when it sends reports""" + + print("Discovery mode, press Ctrl-C to stop") + energenie.discovery_ask(energenie.ask) + try: + while True: + energenie.loop() # Allow receive processing + time.sleep(0.25) # tick fast enough to get messages in quite quickly + + except KeyboardInterrupt: + print("Discovery stopped") + + +def do_list_registry(): + """List the entries in the registry""" + + print("REGISTRY:") + show_registry() + + +def do_switch_device(): + """Turn the switch on a socket on and off, to test it""" + + global quit + + name = get_device_name() + device = energenie.registry.get(name) + + def on(): + print("Turning on") + device.turn_on() + + def off(): + print("Turning off") + device.turn_off() + + MENU = [ + ("on", on), + ("off", off) + ] + + try: + while not quit: + show_menu(MENU) + choice = get_choice((1,len(MENU))) + if choice != None: + handle_choice(MENU, choice) + + except KeyboardInterrupt: + pass # user exit + quit = False + + +def do_show_device_status(): + """Show the readings associated with a device""" + + name = get_device_name() + device = energenie.registry.get(name) + + readings = device.get_readings_summary() + print(readings) + + +def do_watch_devices(): + """Repeatedly show readings for all devices""" + + print("Watching devices, Ctrl-C to stop") + try: + while True: + energenie.loop() # allow receive processing + + print('-' * 80) + names = energenie.registry.names() + for name in names: + device = energenie.registry.get(name) + readings = device.get_readings_summary() + print("%s %s" % (name, readings)) + print("") + time.sleep(1) + + except KeyboardInterrupt: + pass # user exit + + +def do_rename_device(): + """Rename a device in the registry to a different name""" + + # This is useful when turning auto discovered names into your own names + + old_name = get_device_name() + if old_name == None: return # user abort + + try: + new_name = readin("New name? ") + except KeyboardInterrupt: + return # user abort + + energenie.registry.rename(old_name, new_name) + print("Renamed OK") + + +def do_delete_device(): + """Delete a device from the registry so it is no longer recognised""" + + name = get_device_name() + if name == None: return #user abort + + energenie.registry.delete(name) + print("Deleted OK") + + +def do_logging(): + """Enter a mode where all communications are logged to screen and a file""" + + import Logger + + # provide a default incoming message handler for all fsk messages + def incoming(address, message): + print("\nIncoming from %s" % str(address)) + print(message) + Logger.logMessage(message) + energenie.fsk_router.when_incoming(incoming) + + print("Logging enabled, Ctrl-C to stop") + try: + while True: + energenie.loop() + + except KeyboardInterrupt: + pass #user quit + + finally: + energenie.fsk_router.when_incoming(None) + + +def do_quit(): + """Finished with the program, so exit""" + + global quit + quit = True + + +#===== MENU =================================================================== + +def show_menu(menu): + """Display a menu on the console""" + + i = 1 + for item in menu: + print("%d. %s" % (i, item[0])) + i += 1 + + +def get_choice(choices): + """Get and validate a numberic choice from the tuple choices(first, last)""" + + first = choices[0] + last = choices[1] + try: + while True: + choice = readin("Choose %d to %d? " % (first, last)) + try: + choice = int(choice) + if choice < first or choice > last: + print("Must enter a number between %d and %d" % (first, last)) + else: + return choice + except ValueError: + print("Must enter a number") + except KeyboardInterrupt: + do_quit() + + +def handle_choice(menu, choice): + """Route to the handler for the given menu choice""" + + menu[choice-1][1]() + + +MAIN_MENU = [ + ("legacy learn mode", do_legacy_learn), + ("mihome discovery mode", do_mihome_discovery), + ("list registry", do_list_registry), + ("switch device", do_switch_device), + ("show device status", do_show_device_status), + ("watch devices", do_watch_devices), + ("rename device", do_rename_device), + ("delete device", do_delete_device), + ("logging", do_logging), + ("quit", do_quit) +] + + +#===== MAIN PROGRAM =========================================================== + +def setup_tool(): + """The main program loop""" + + while not quit: + print("\nMAIN MENU") + show_menu(MAIN_MENU) + choice = get_choice((1,len(MAIN_MENU))) + if not quit: + print("\n") + handle_choice(MAIN_MENU, choice) + + +if __name__ == "__main__": + + energenie.init() + try: + setup_tool() + finally: + energenie.finished() + + +# END + + diff --git a/src/switch.py b/src/switch.py deleted file mode 100644 index 2fece13..0000000 --- a/src/switch.py +++ /dev/null @@ -1,121 +0,0 @@ -# switch.py 17/03/2016 D.J.Whale -# -# Control Energenie switches. -# Note, at the moment, this only works with MiHome Adaptor Plus devices -# because the 'sensorid' is discovered via the monitor message. -# You could probably fake it by preloading the directory with your sensorid -# if you know what it is. - -# Note, this is *only* a test program, to exercise the lower level code. -# Don't expect this to be a good starting point for an application. -# Consider waiting for me to finish developing the device object interface first. - -import time -from energenie import Devices, Messages, Registry, OpenThings, radio -from Timer import Timer - - -# Increase this if you have lots of switches, so that the receiver has enough -# time to receive update messages, otherwise your devices won't make it into -# the device directory. -TX_RATE = 2 # seconds between each switch change cycle - - -def warning(msg): - print("warning:%s" % str(msg)) - - -def trace(msg): - print("monitor:%s" % str(msg)) - - -#----- TEST APPLICATION ------------------------------------------------------- - -def switch_sniff_loop(): - """Listen to sensor messages and add them to the Registry""" - - # See if there is a payload, and if there is, process it - if radio.is_receive_waiting(): - ##trace("receiving payload") - payload = radio.receive() - try: - decoded = OpenThings.decode(payload) - now = time.time() - except OpenThings.OpenThingsException as e: - warning("Can't decode payload:" + str(e)) - return - - OpenThings.showMessage(decoded, timestamp=now) - # Any device that reports will be added to the non-persistent directory - Registry.update(decoded) - ##trace(decoded) - - # Process any JOIN messages by sending back a JOIN-ACK to turn the LED off - if len(decoded["recs"]) == 0: - # handle messages with zero recs in them silently - print("Empty record:%s" % decoded) - else: - # assume only 1 rec in a join, for now - if decoded["recs"][0]["paramid"] == OpenThings.PARAM_JOIN: - mfrid = OpenThings.getFromMessage(decoded, "header_mfrid") - productid = OpenThings.getFromMessage(decoded, "header_productid") - sensorid = OpenThings.getFromMessage(decoded, "header_sensorid") - Messages.send_join_ack(radio, mfrid, productid, sensorid) - - -def switch_toggle_loop(): - """Toggle the switch on all devices in the directory""" - - global switch_state - - if Registry.size() > 0 and sendSwitchTimer.check(): - print("transmit") - radio.transmitter() - - for sensorid in Registry.get_sensorids(): - # Only try to toggle the switch for devices that actually have a switch - header = Registry.get_info(sensorid)["header"] - mfrid = header["mfrid"] - productid = header["productid"] - - if Devices.hasSwitch(mfrid, productid): - request = OpenThings.alterMessage(Messages.SWITCH, - header_sensorid=sensorid, - recs_0_value=switch_state) - p = OpenThings.encode(request) - print("Sending switch message to %s %s" % (hex(productid), hex(sensorid))) - # Transmit multiple times, hope one of them gets through - radio.transmit(p, inner_times=2) - - radio.receiver() - switch_state = (switch_state+1) % 2 # toggle - - -if __name__ == "__main__": - - trace("starting switch tester") - radio.init() - OpenThings.init(Devices.CRYPT_PID) - - # Seed the registry with a known device, to simplify tx-only testing - SENSOR_ID = 0x68B # captured from a real device - device_header = OpenThings.alterMessage(Messages.REGISTERED_SENSOR, - header_mfrid = Devices.MFRID, - header_productid = Devices.PRODUCTID_MIHO005, # adaptor plus - header_sensorid = SENSOR_ID) - Registry.update(device_header) - - - sendSwitchTimer = Timer(TX_RATE, 1) # every n seconds offset by initial 1 - switch_state = 0 # OFF - radio.receiver() - - try: - while True: - switch_sniff_loop() - switch_toggle_loop() - - finally: - radio.finished() - -# END diff --git a/src/web_console.py b/src/web_console.py new file mode 100644 index 0000000..1eb701c --- /dev/null +++ b/src/web_console.py @@ -0,0 +1,17 @@ +# web_console.py 28/05/2016 D.J.Whale +# +# A demo of a simple web console for controlling/monitoring Energenie devices + +# This is mostly a very simple + +# REQUIREMENTS +# - zero install (probably python bottle) +# - simple monitor/control interface +# - live status display of all registered devices with values and states +# - turn on/off devices that have a switch +# - simple setup tool interface +# - enter/exit learn mode for legacy +# - change discovery status +# - rename/delete devices in registry (e.g. from auto learn) + +# END diff --git a/test/TESTS.txt b/test/TESTS.txt index 646181c..402adad 100644 --- a/test/TESTS.txt +++ b/test/TESTS.txt @@ -15,92 +15,25 @@ git add radio_rpi.so -2. legacy.py runs on Raspberry Pi +2. setup_tool works python 2/3 + 2.1. legacy learn mode + 2.2. mihome discovery mode + 2.3. list registry + 2.4. switch device + 2.5. show device status + 2.6. watch devices + 2.7. rename device + 2.8. delete device + 2.9. logging -cd src -sudo python legacy.py +3. control_any_auto.py works python 2/3 -("do you want to learn any switches?" Y) -("Learn switch 1?" Y) -("Press the LEARN button on any switch 1 for 5 secs until LED flashes") -("press ENTER when LED is flashing") - (hold green button for 10 seconds for fast flash to clear pairing memory) - (press ENTER) -("ON") -("Device should now be programmed") -("Testing....") -("OFF") -("ON") -("OFF") -("ON") -("OFF") -("ON") - (say no to learn other switches) -("switch 0 ON") - (turns on due to 'all switches on') -("switch 0 OFF") - (turns off due to 'all switches off') -("switch 1 ON") - (turns on) -("switch 1 OFF") - (turns off) -("switch 2 ON") -("switch 2 OFF") -("switch 3 ON") -("switch 3 OFF") -("switch 4 ON") -("switch 4 OFF") +4. control_any_noreg.py works python 2/3 +5. control_any_reg.py works python 2/3 -3. monitor.py +6. discover_mihome.py works python 2/3 -cd src -sudo python monitor.py - -(plug in a MiHome adaptor plus) -(press button to force it to send a report) - -("monitor:1463122760.36,4,2,1675,1111100,1,243,49.8984375,0,0,None,None") -("mfrid:0x4 prodid:0x2 sensorid:0x68b") -("read REAL_POWER W = 0") -("read REACTIVE_POWER VAR = 0") -("read VOLTAGE V = 242") -("read FREQUENCY Hz = 49.8984375") -("read SWITCH_STATE = 1") -("monitor:1463122770.34,4,2,1675,1111100,1,242,49.8984375,0,0,None,None") - - -(plug in a MiHome house monitor) -(wait a few seconds for a report to come back) - -("read APPARENT_POWER VA = 0") -("read VOLTAGE V = 4.65625") -("read CURRENT A = 0.0") -("ADD device:0x1b9 Manufacturer:Energenie Product:MIHO006 HOUSE MONITOR") -("monitor:1463122944.38,4,5,441,0100011,None,4.65625,None,None,None,0,0.0") - - -(put batteries in a MiHome eTRV) -(wait a few seconds for a join report to come back) - -("mfrid:0x4 prodid:0x3 sensorid:0xc2a") -("read VOLTAGE V = 3.05859375") -("ADD device:0xc2a Manufacturer:Energenie Product:MIHO013 ETRV") -("monitor:1463123313.37,4,3,3114,0100000,None,3.05859375,None,None,None,None,None") -("mfrid:0x4 prodid:0x3 sensorid:0xc2a") -("monitor:1463123315.15,4,3,3114,0000000,None,None,None,None,None,None,None") -("Empty record:{'header': {'sensorid': 3114, 'productid': 3, 'encryptPIP': 17443, 'mfrid': 4}, 'type': 'OK', 'recs': []}") -("mfrid:0x4 prodid:0x3 sensorid:0xc2a") -("read TEMPERATURE C = 22.0") -("monitor:1463123323.41,4,3,3114,0000000,None,None,None,None,None,None,None") - - -4. switch.py - -cd src -sudo python switch.py - -(plug in a MiHome adaptor plus, should report) -(every 10 seconds it should toggle it's on/of state) +7. mihome_energy_monitor.py works python 2/3 END