diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 7a015a38..d89f2a37 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.2 +current_version = 0.7.3 commit = True tag = False diff --git a/HISTORY.md b/HISTORY.md index 4580fe35..94e80f99 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,7 +1,36 @@ # Revision Change History +## [0.7.4] + +### Additions + +- Major improvements to the IOLinc support. In short all functions of the + device should now be supported. Including momentary modes in which the + relay opens for a defined period of time before closing again. Specific + topics have been added for the relay and the sensor so they can both be + tracked individually. ([PR 197][P197]) BREAKING CHANGE - the scene_topic + has been elimited, please see the notes below for replacement functionality. + Please see notes in: + - [config.yaml](https://github.com/TD22057/insteon-mqtt/blob/master/config.yaml) - + specifically the IOLinc sections in both the device and mqtt sections + - [MQTT Doc](https://github.com/TD22057/insteon-mqtt/blob/master/docs/mqtt.md) - + note the new set_flags options for IOLinc and the IOLinc section + + - A new queueing system for battery devices ([PR240][P240]): + - Messages sent to the device will be queued until the device is awake + - When the device sends a message, the modem will attempt to immediately + send the oldest outgoing message. This only works for some devices. + - Added an 'awake' command, to identify when a battery device has been + manually awaken via holding the set button. This will cause all queued + and future messages to be sent to the device for up to three minutes + +### Fixes + ## [0.7.3] +Fixing a number of small bugs in preparation for upcoming releases which +will add new features. + ### Additions - Added MQTT broker ID optional config input to allow the user to input the @@ -9,10 +38,21 @@ ### Fixes +- Increase timeout for DB Refresh and allow retry for initial request. + ([PR #237][P237]) + +- Detect disconnections during poll() calls (thanks @kpfleming) ([PR 227][P227]) + +- Modem Responder Group from Thermostat Should be 0x01 ([PR #198][P198]) + ([Issue 154][I154]) + +- Fixed device db find command to check the local group so multiple responsders + can be created. ([Issue #181][I181]) + - Fixed a bug in the modem database class when removing an entry (thanks @krkeegan) ([PR#196][P196]) -- Changed the MQTT remote to never mark messages for retain so the broker +- Changed the MQTT Remote to never mark messages for retain so the broker doesn't get out of sync with the device. ([Issue #I210][I210]) @@ -383,3 +423,9 @@ [P196]: https://github.com/TD22057/insteon-mqtt/pull/196 [I210]: https://github.com/TD22057/insteon-mqtt/issues/210 [P220]: https://github.com/TD22057/insteon-mqtt/pull/220 +[I181]: https://github.com/TD22057/insteon-mqtt/issues/181 +[I154]: https://github.com/TD22057/insteon-mqtt/issues/154 +[P227]: https://github.com/TD22057/insteon-mqtt/pull/227 +[P237]: https://github.com/TD22057/insteon-mqtt/pull/227 +[P197]: https://github.com/TD22057/insteon-mqtt/pull/197 +[P240]: https://github.com/TD22057/insteon-mqtt/pull/240 diff --git a/README.md b/README.md index b2226daa..1d8d8be3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ My initial intent with this package is better integrate Insteon into Home Assistant and make it easier and more understandable to add new features and devices. -Version: 0.7.2 ([History](HISTORY.md)) +Version: 0.7.3 ([History](HISTORY.md)) ### Breaking changes from last version: diff --git a/config.yaml b/config.yaml index 543b170f..4ee7a310 100644 --- a/config.yaml +++ b/config.yaml @@ -377,6 +377,17 @@ mqtt: low_battery_topic: 'insteon/{{address}}/battery' low_battery_payload: '{{is_low_str.upper()}}' + # Output heartbeat topic and payload. This message is sent + # every 24 hours. Available variables for templating are: + # address = 'aa.bb.cc' + # name = 'device name' + # is_heartbeat = 0/1 + # heartbeat_time = UNIX time float of the last heartbeat + # Not every battery device currently sends this data. Currently only + # the leak sensor is known to provide this data + heartbeat_topic: 'insteon/{{address}}/heartbeat' + heartbeat_payload: '{{heartbeat_time}}' + #------------------------------------------------------------------------ # Motion sensors #------------------------------------------------------------------------ @@ -409,6 +420,9 @@ mqtt: # Leak sensors #------------------------------------------------------------------------ + # Leak sensors will use the heartbeat configuration + # inputs from battery_sensor. + # # Leak sensors will report the dry/wet status and a heartbeat every 24 # hours. The leak sensors does not support low battery signal like other # battery operated devices. @@ -437,14 +451,38 @@ mqtt: wet_dry_topic: 'insteon/{{address}}/wet' wet_dry_payload: '{{is_wet_str.upper()}}' - # Output heartbeat topic and payload. This message is sent - # every 24 hours. Available variables for templating are: + #------------------------------------------------------------------------ + # Mini remotes + #------------------------------------------------------------------------ + + # Battery powered remotes (usually 4 or 8 buttons). A message is + # sent whenever one of the buttons is pressed. + # + # The Remote will use the low_battery configuration + # inputs from battery_sensor. + remote: + # Output state change topic and template. This message is sent + # whenever a button is pressed. Available variables for templating are: # address = 'aa.bb.cc' # name = 'device name' - # is_heartbeat = 0/1 - # heartbeat_time = UNIX time float of the last heartbeat - heartbeat_topic: 'insteon/{{address}}/heartbeat' - heartbeat_payload: '{{heartbeat_time}}' + # button = 1...n (button number 1-8 depending on configuration) + # on = 0/1 + # on_str = 'off'/'on' + # mode = 'normal'/'fast'/'instant' + # fast = 0/1 + # instant = 0/1 + state_topic: 'insteon/{{address}}/state/{{button}}' + state_payload: '{{on_str.upper()}}' + + # Manual mode (holding down a button) is triggered once when the button + # is held and once when it's released. Available variables for + # templating are address (see above), name (see above), button (see + # above), and: + # manual_str = 'up'/'off'/'down' + # manual = 1/0/-1 + # manual_openhab = 2/1/0 + #manual_state_topic: 'insteon/{{address}}/manual_state' + #manual_state_payload: '{{manual_str.upper()}}' #------------------------------------------------------------------------ # Smoke Bridge @@ -584,36 +622,6 @@ mqtt: cool_sp_command_topic: 'insteon/{{address}}/cool_sp_command' cool_sp_payload: '{ "temp_f" : {{value}} }' - #------------------------------------------------------------------------ - # Mini remotes - #------------------------------------------------------------------------ - - # Battery powered remotes (usually 4 or 8 buttons). A message is - # sent whenever one of the buttons is pressed. - remote: - # Output state change topic and template. This message is sent - # whenever a button is pressed. Available variables for templating are: - # address = 'aa.bb.cc' - # name = 'device name' - # button = 1...n (button number 1-8 depending on configuration) - # on = 0/1 - # on_str = 'off'/'on' - # mode = 'normal'/'fast'/'instant' - # fast = 0/1 - # instant = 0/1 - state_topic: 'insteon/{{address}}/state/{{button}}' - state_payload: '{{on_str.upper()}}' - - # Manual mode (holding down a button) is triggered once when the button - # is held and once when it's released. Available variables for - # templating are address (see above), name (see above), button (see - # above), and: - # manual_str = 'up'/'off'/'down' - # manual = 1/0/-1 - # manual_openhab = 2/1/0 - #manual_state_topic: 'insteon/{{address}}/manual_state' - #manual_state_payload: '{{manual_str.upper()}}' - #------------------------------------------------------------------------ # Fan Linc #------------------------------------------------------------------------ @@ -828,10 +836,13 @@ mqtt: # then the on and off commands work like a normal switch. The set-flags # command line command can be used to change the settings. # - # NOTE: the on/off payload forces the relay to on or off so it's most - # likely NOT the way you want to use this. The scene payload is the same - # trigger the IOLinc as a responder which respects the sensors settings in - # momentary-C mode and is most likely the way you do want to use this. + # NOTE: the on/off payload forces the relay to on or off ignoring any special + # requirements associated with the Momentary_A,B,C functions or the + # relay_linked flag. It can be used without issue for latching setups, but + # if you want to use this accurately for Momentary_A,B,C setups, you may need + # to have some logic upstream from this command to check the state of sensor + # and or the command to determine if setting the relay on or off is + # appropriate . # # In Home Assistant use MQTT switch with a configuration like: # switch: @@ -840,17 +851,39 @@ mqtt: # command_topic: 'insteon/aa.bb.cc/scene' io_linc: # Output state change topic and template. This message is sent whenever - # the device sensor state changes. Available variables for templating - # are: + # the device sensor or device relay state changes. Available variables for + # templating are: # address = 'aa.bb.cc' # name = 'device name' - # on = 0/1 - # on_str = 'off'/'on' + # sensor_on = 0/1 + # relay_on = 0/1 + # sensor_on_str = 'off'/'on' + # relay_on_str = 'off'/'on' state_topic: 'insteon/{{address}}/state' - state_payload: '{{on_str.upper()}}' + state_payload: '{ "sensor" : "{{sensor_on_str.lower()}}", "relay" : {{relay_on_str.lower()}} }' + + # Output relay state change topic and template. This message is sent + # whenever the device relay state changes. Available variables for + # templating are: + # address = 'aa.bb.cc' + # name = 'device name' + # relay_on = 0/1 + # relay_on_str = 'off'/'on' + relay_state_topic: 'insteon/{{address}}/relay' + relay_state_payload: '{{relay_on_str.lower()}}' + + # Output sensor state change topic and template. This message is sent + # whenever the device sensor state changes. Available variables for + # templating are: + # address = 'aa.bb.cc' + # name = 'device name' + # sensor_on = 0/1 + # sensor_on = 'off'/'on' + sensor_state_topic: 'insteon/{{address}}/sensor' + sensor_state_payload: '{{sensor_on_str.lower()}}' # Input on/off command. This forces the relay on/off and ignores the - # momentary-C sensor setting. Use this to force the relay to respond. + # momentary-A,B,C setting. Use this to force the relay to respond. # If momentary mode is active, it will turn off after the delay. The # output of passing the payload through the template must match the # following: @@ -862,13 +895,6 @@ mqtt: on_off_topic: 'insteon/{{address}}/set' on_off_payload: '{ "cmd" : "{{value.lower()}}" }' - # Scene on/off command. This triggers the IOLinc as if it were a - # responder to a scene command and is the "correct" way to trigger the - # IOLinc relay in that it respects the momentary settings. The inputs - # are the same as those for the on_off topic and payload. - scene_topic: 'insteon/{{address}}/scene' - scene_payload: '{ "cmd" : "{{value.lower()}}" }' - #------------------------------------------------------------------------ # On/off outlets #------------------------------------------------------------------------ diff --git a/docs/mqtt.md b/docs/mqtt.md index 8be6f0b2..b3cf802f 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -38,6 +38,7 @@ be identified by it's address or the string "modem". - [Remotes](#remote-controls) - [Smoke Bridge](#smoke-bridge) - [Switches](#switches) + - [Thermostat](#thermostat) ## Required Device Initialization @@ -477,6 +478,11 @@ IOLinc supports the flags: change the relay mode (see the IOLinc user's guide for details) - trigger_reverse: 0/1 reverses the trigger command state - relay_linked: 0/1 links the relay to the sensor value + - momentary_secs: .1-6300 the number of seconds the relay stays closed in + momentary mode. There is finer resolution at the low end. Higher + values will be rounded to the next valid value. Setting this to 0 + will cause the IOLinc to change to latching mode. Setting this to + a non-zero value will cause the IOLinc to change to momentary mode. Motion Sensors support the flags: @@ -534,6 +540,28 @@ will be passed through to the output state change payload. ``` +### Mark a battery device as awake. + +Supported: battery devices only + +Normally battery devices are sleeping and will not respond to commands. So +normally, the program queues messages for these devices and attempts to send +them when the device is awake. Usually this happens for a short period of time +after the device sends a message. But some battery devices do not even respond +to commands during this time. + +You can manually wake a battery device by up by holding in their set buttons +until their light flashes. At this point they will stay awake for +approximately 3 minutes. + +If you manually wake up a device using this method, then call this command +so that the program knows that it can send messages to the device for the +next three minutes. + + ``` + { "cmd": "awake" } + ``` + --- # State change commands @@ -906,6 +934,18 @@ templates: - 'is_low' is 1 for a low battery, 0 for normal. - 'is_low_str' is 'on' for a low battery, 'off' for normal. +Some battery sensors also issues a heartbeat every 24 hours that can be used +to confirm that they are still working. Presently, only the Leak sensor is +known to use heartbeat messages. The following variables can be used for +templates: + + - "is_heartbeat" is 1 whenever a heartbeat occurs + - "is_heartbeat_str" is "on" whenever a heartbeat occurs + - "heartbeat_time" is the Unix timestamp of when the heartbeat occurred + +The Battery Sensor class is also the base for other battery devices that +have additional features, namely Motion Sensors, Leak Sensors, and Remotes. + A sample battery sensor topic and payload configuration is: ``` @@ -917,6 +957,10 @@ A sample battery sensor topic and payload configuration is: # Low battery warning low_battery_topic: 'insteon/{{address}}/battery' low_battery_payload: '{{is_low_str.upper()}}' + + # Heartbeats + heartbeat_topic: 'insteon/{{address}}/heartbeat' + heartbeat_payload: '{{heartbeat_time}}' ``` --- @@ -968,8 +1012,6 @@ A sample leak sensor topic and payload configuration is: leak: wet_dry_topic: 'insteon/{{address}}/wet' wet_dry_payload: '{{state.upper()}}' - heartbeat_topic: 'insteon/{{address}}/heartbeat' - heartbeat_payload: '{{heartbeat_time}}' ``` --- @@ -1266,3 +1308,91 @@ Payload: { "cmd" : "get_status"} ``` --- + +## IOLinc + +The IOLinc is both a switch (momentary or latching on/off) and a sensor +that can be on or off. There is a state topic which returns the state of both +objects, as well as individual relay and sensor topics that only return the +state of those objects. + +The set-flags command line command can be used to change the mode settings. + +There is also a set topic similar to other devices. This obviously can only +be used to set the state of the relay. However it may not work as you expect: + +- In Latching mode, the set function works like any other switch. Turning the +relay on and off accordingly. +- If you configure the IOLinc to be momentary, then the ON command will trigger +the relay to turn on for the duration that is configured then off. An OFF the +command will only cause the relay to turn off, it it is still on because the +momentary duration has not fully elapsed yet. +- The on/off payload forces the relay to on or off IGNORING any special +requirements associated with the Momentary_A,B,C functions or the +relay_linked flag. + +If you want a command that respects the Momentary_A,B,C requirements, you want +to create a modem scene and to issue the commands to that scene. See +[Scene triggering](#scene triggering) for a description for how to issue +commands to a scene. And see [Scene Management](scenes.md) for a description +of how to make a scene and the scenes.yaml file for examples of an IOLinc +scene. + +In Home Assistant use MQTT switch with a configuration like: + switch: + - platform: mqtt + state_topic: 'insteon/aa.bb.cc/relay' + command_topic: 'insteon/aa.bb.cc/set' + binary_sensor: + - platform: mqtt + state_topic: 'insteon/aa.bb.cc/sensor' + +Alternatively, to use a modem scene to control the IOLinc + switch: + - platform: mqtt + state_topic: 'insteon/aa.bb.cc/relay' + command_topic: "insteon/command/modem" + payload_off: '{ "cmd": "scene", "name" : "<>", "is_on" : 0}' + payload_on: '{ "cmd": "scene", "name" : "<>", "is_on" : 1}' + +State Topic: + ``` + 'insteon/{{address}}/state' + ``` + +State Topic Payload: + ``` + '{ "sensor" : "{{sensor_on_str.lower()}}"", relay" : {{relay_on_str.lower()}} }' + ``` + +Relay State Topic: + ``` + 'insteon/{{address}}/relay' + ``` + +Payload: + ``` + '{{relay_on_str.lower()}}' + ``` + +Sensor State Topic: + ``` + 'insteon/{{address}}/sensor' + ``` + +Payload: + ``` + '{{sensor_on_str.lower()}}' + ``` + +Set Command Topic: + ``` + 'insteon/{{address}}/set' + ``` + +Payload: + ``` + '{ "cmd" : "{{value.lower()}}" }' + ``` + +--- \ No newline at end of file diff --git a/hassio/config.json b/hassio/config.json index 6ce79387..8abec6db 100644 --- a/hassio/config.json +++ b/hassio/config.json @@ -2,7 +2,7 @@ "name": "Insteon MQTT", "description": "Python Insteon PLM <-> MQTT bridge", "slug": "insteon-mqtt", - "version": "0.7.2", + "version": "0.7.3", "startup": "services", "arch": ["amd64","armhf","aarch64","i386"], "boot": "auto", diff --git a/insteon_mqtt/CommandSeq.py b/insteon_mqtt/CommandSeq.py index 96e10c05..5d81e38e 100644 --- a/insteon_mqtt/CommandSeq.py +++ b/insteon_mqtt/CommandSeq.py @@ -27,19 +27,18 @@ class CommandSeq: this library needs, it works ok. """ #----------------------------------------------------------------------- - def __init__(self, protocol, msg=None, on_done=None, error_stop=True): + def __init__(self, device, msg=None, on_done=None, error_stop=True): """Constructor Args: - protocol (Protocol): The Protocol object to use. This can also be a - device.Base object. + device (Device): The device that these messages are sent to. msg (str): String message to pass to on_done if the sequence works. on_done: The callback to run when complete. This will be run when there is an error or when all the commands finish. error_stop (bool): True to stop the sequence if a command fails. False to continue on with the sequence. """ - self.protocol = protocol + self.device = device self._on_done = util.make_callback(on_done) self.msg = msg @@ -130,7 +129,7 @@ def on_done(self, success, msg, data): len(self.calls), self.total) entry = self.calls.pop(0) - entry.run(self.protocol, self.on_done) + entry.run(self.device, self.on_done) #----------------------------------------------------------------------- @@ -185,17 +184,17 @@ def from_msg(cls, msg, handler): return obj #----------------------------------------------------------------------- - def run(self, protocol, on_done): + def run(self, device, on_done): """Run the command. Args: - protocol: The Protocol object to use to send messages. + device: The Device object to use to send messages. on_done: The finished calllback. This will be passed to the handler or the function. """ if self.func is None: self.handler.on_done = on_done - protocol.send(self.msg, self.handler) + device.send(self.msg, self.handler) else: self.func(*self.args, on_done=on_done, **self.kwargs) diff --git a/insteon_mqtt/Modem.py b/insteon_mqtt/Modem.py index e838713c..58c23f3a 100644 --- a/insteon_mqtt/Modem.py +++ b/insteon_mqtt/Modem.py @@ -28,7 +28,7 @@ class Modem: input). This allows devices to be looked up by address to send commands to those devices. """ - def __init__(self, protocol, stack): + def __init__(self, protocol, stack, timed_call): """Constructor Actual modem definitions must be loaded from a configuration file via @@ -36,9 +36,12 @@ def __init__(self, protocol, stack): Args: protocol (Protocol): Insteon message handling protocol object. + stack (Stack): The link to the Stack handling object + timed_call (TimedCall): The link to the TimedCall handling object """ self.protocol = protocol self.stack = stack + self.timed_call = timed_call self.addr = None self.name = "modem" @@ -192,7 +195,7 @@ def refresh(self, force=False, on_done=None): # request each next record as the records arrive. msg = Msg.OutAllLinkGetFirst() msg_handler = handler.ModemDbGet(self.db, on_done) - self.protocol.send(msg, msg_handler) + self.send(msg, msg_handler) #----------------------------------------------------------------------- def db_path(self): @@ -336,7 +339,7 @@ def refresh_all(self, battery=False, force=False, on_done=None): """ # Set the error stop to false so a failed refresh doesn't stop the # sequence from trying to refresh other devices. - seq = CommandSeq(self.protocol, "Refresh all complete", on_done, + seq = CommandSeq(self, "Refresh all complete", on_done, error_stop=False) # Reload the modem database. @@ -371,7 +374,7 @@ def get_engine_all(self, battery=False, on_done=None): """ # Set the error stop to false so a failed refresh doesn't stop the # sequence from trying to refresh other devices. - seq = CommandSeq(self.protocol, "Get Engine all complete", on_done, + seq = CommandSeq(self, "Get Engine all complete", on_done, error_stop=False) # Reload all the device databases. @@ -608,6 +611,26 @@ def factory_reset(self, on_done=None): LOG.warning("Modem being reset. All data will be lost") msg = Msg.OutResetModem() msg_handler = handler.ModemReset(self, on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def send(self, msg, msg_handler, high_priority=False, after=None): + """Send a message to the modem. + + This simply forwards to Protocol.Send() but is here to provide + consistency with devices. + + Args: + msg (Message): Output message to write. This should be an + instance of a message in the message directory that that starts + with 'Out'. + msg_handler (MsgHander): Message handler instance to use when + replies to the message are received. Any message + received after we write out the msg are passed to this + handler until the handler returns the message.FINISHED + flags. + """ + self.protocol.send(msg, msg_handler) #----------------------------------------------------------------------- @@ -647,7 +670,7 @@ def sync(self, dry_run=True, refresh=True, sequence=None, on_done=None): if sequence is not None: seq = sequence else: - seq = CommandSeq(self.protocol, "Sync complete", on_done, + seq = CommandSeq(self, "Sync complete", on_done, error_stop=False) if refresh: @@ -682,7 +705,7 @@ def _sync_del(self, entry, dry_run, on_done=None): on_done(True, None, None) else: LOG.ui(" Deleting %s:", entry) - self.db.delete_on_device(self.protocol, entry, on_done=on_done) + self.db.delete_on_device(entry, on_done=on_done) def _sync_add(self, entry, dry_run, on_done=None): ''' Adds a link to the device with a Log UI Message @@ -694,7 +717,7 @@ def _sync_add(self, entry, dry_run, on_done=None): on_done(True, None, None) else: LOG.ui(" Adding %s:", entry) - self.db.add_on_device(self.protocol, entry, on_done=on_done) + self.db.add_on_device(entry, on_done=on_done) #----------------------------------------------------------------------- def sync_all(self, dry_run=True, refresh=True, on_done=None): @@ -712,7 +735,7 @@ def sync_all(self, dry_run=True, refresh=True, on_done=None): """ # Set the error stop to false so a failed refresh doesn't stop the # sequence from trying to refresh other devices. - seq = CommandSeq(self.protocol, "Sync All complete", on_done, + seq = CommandSeq(self, "Sync All complete", on_done, error_stop=False) # First the modem database. @@ -837,7 +860,7 @@ def linking(self, group=0x01, on_done=None): # nothing happens. See the handler for details. msg = Msg.OutModemLinking(Msg.OutModemLinking.Cmd.EITHER, group) msg_handler = handler.ModemLinkStart(on_done) - self.protocol.send(msg, msg_handler) + self.send(msg, msg_handler) #----------------------------------------------------------------------- def link_data(self, is_controller, group, data=None): @@ -964,7 +987,7 @@ def scene(self, is_on, group=None, name=None, num_retry=3, reason="", cmd1 = 0x11 if is_on else 0x13 msg = Msg.OutModemScene(group, cmd1, 0x00) msg_handler = handler.ModemScene(self, msg, on_done) - self.protocol.send(msg, msg_handler) + self.send(msg, msg_handler) #----------------------------------------------------------------------- def handle_received(self, msg): @@ -1142,13 +1165,13 @@ def _db_update(self, local_group, is_controller, remote_addr, remote_group, # discussion. local_data = self.link_data(is_controller, local_group, local_data) - seq = CommandSeq(self.protocol, "Device db update complete", on_done) + seq = CommandSeq(self, "Device db update complete", on_done) # Create a new database entry for the modem and send it to the modem # for updating. entry = db.ModemEntry(remote_addr, local_group, is_controller, local_data) - seq.add(self.db.add_on_device, self.protocol, entry) + seq.add(self.db.add_on_device, entry) # For two way commands, insert a callback so that when the modem # command finishes, it will send the next command to the device. @@ -1200,8 +1223,8 @@ def _db_delete(self, addr, group, is_controller, two_way, refresh, return # Add the function delete call to the sequence. - seq = CommandSeq(self.protocol, "Delete complete", on_done) - seq.add(self.db.delete_on_device, self.protocol, entry) + seq = CommandSeq(self, "Delete complete", on_done) + seq.add(self.db.delete_on_device, entry) # For two way commands, insert a callback so that when the modem # command finishes, it will send the next command to the device. diff --git a/insteon_mqtt/Protocol.py b/insteon_mqtt/Protocol.py index 74cfdf67..50857c2d 100644 --- a/insteon_mqtt/Protocol.py +++ b/insteon_mqtt/Protocol.py @@ -88,6 +88,11 @@ def __init__(self, link): # Message received signal. Every read message is passed to this. self.signal_received = Signal() # (Message) + # Message finished signal. Every write message that completes with + # Msg.FINISHED, will be emitted here. Notably happens AFTER msg has + # been removed from the _write_queue + self.signal_msg_finished = Signal() # (Message) + # Inbound message buffer. self._buf = bytearray() @@ -232,6 +237,20 @@ def set_wait_time(self, wait_time): """ self._next_write_time = wait_time + #----------------------------------------------------------------------- + def is_addr_in_write_queue(self, addr): + """Checks whether a message to the specified address already exists + in the _write_queue + + Args: + addr (Address): The address to search for. + """ + for out in self._write_queue: + if isinstance(out.msg, (Msg.OutExtended, Msg.OutStandard)): + if out.msg.to_addr == addr: + return True + return False + #----------------------------------------------------------------------- def _poll(self, t): """Periodic polling function. @@ -421,6 +440,8 @@ def _process_msg(self, msg): if status == Msg.FINISHED: LOG.debug("Write handler finished") self._write_finished() + # Notify any listeners that msg FINISHED + self.signal_msg_finished.emit(msg) return # If this message was understood by the write handler, don't look diff --git a/insteon_mqtt/__init__.py b/insteon_mqtt/__init__.py index 8c20c9fe..aa9a07d3 100644 --- a/insteon_mqtt/__init__.py +++ b/insteon_mqtt/__init__.py @@ -10,7 +10,7 @@ For docs, see: https://www.github.com/TD22057/insteon-mqtt """ -__version__ = "0.7.2" +__version__ = "0.7.3" #=========================================================================== diff --git a/insteon_mqtt/cmd_line/device.py b/insteon_mqtt/cmd_line/device.py index 0aae6309..431ca92f 100644 --- a/insteon_mqtt/cmd_line/device.py +++ b/insteon_mqtt/cmd_line/device.py @@ -314,4 +314,14 @@ def import_scenes(args, config): reply = util.send(config, topic, payload, args.quiet) return reply["status"] + +#=========================================================================== +def awake(args, config): + topic = "%s/%s" % (args.topic, args.address) + payload = { + "cmd" : "awake", + } + + reply = util.send(config, topic, payload, args.quiet) + return reply["status"] #=========================================================================== diff --git a/insteon_mqtt/cmd_line/main.py b/insteon_mqtt/cmd_line/main.py index 5720b9d0..739f44e7 100644 --- a/insteon_mqtt/cmd_line/main.py +++ b/insteon_mqtt/cmd_line/main.py @@ -384,6 +384,17 @@ def parse_args(args): help="Don't print any command results to the screen.") sp.set_defaults(func=device.import_scenes) + #--------------------------------------- + # device.awake + # Only works on battery devices + sp = sub.add_parser("awake", help="Mark a battery device as being awake." + "Hold the set button on the device until the light " + "blinks to force the device awake for 3 minutes.") + sp.add_argument("address", help="Device address or name.") + sp.add_argument("-q", "--quiet", action="store_true", + help="Don't print any command results to the screen.") + sp.set_defaults(func=device.awake) + return p.parse_args(args) diff --git a/insteon_mqtt/cmd_line/start.py b/insteon_mqtt/cmd_line/start.py index 5450d5c5..8876ad59 100644 --- a/insteon_mqtt/cmd_line/start.py +++ b/insteon_mqtt/cmd_line/start.py @@ -34,16 +34,18 @@ def start(args, cfg): mqtt_link = network.Mqtt() plm_link = network.Serial() stack_link = network.Stack() + timed_link = network.TimedCall() # Add the clients to the event loop. loop.add(mqtt_link, connected=False) loop.add(plm_link, connected=False) loop.add_poll(stack_link) + loop.add_poll(timed_link) # Create the insteon message protocol, modem, and MQTT handler and # link them together. insteon = Protocol(plm_link) - modem = Modem(insteon, stack_link) + modem = Modem(insteon, stack_link, timed_link) mqtt_handler = mqtt.Mqtt(mqtt_link, modem) # Load the configuration data into the objects. diff --git a/insteon_mqtt/db/Device.py b/insteon_mqtt/db/Device.py index 445a5c0f..8557247d 100644 --- a/insteon_mqtt/db/Device.py +++ b/insteon_mqtt/db/Device.py @@ -314,7 +314,7 @@ def __len__(self): return len(self.entries) #----------------------------------------------------------------------- - def add_on_device(self, device, addr, group, is_controller, data, + def add_on_device(self, addr, group, is_controller, data, on_done=None): """Add an entry and push the entry to the Insteon device. @@ -332,8 +332,6 @@ def add_on_device(self, device, addr, group, is_controller, data, on_done( success, message, DeviceEntry ) Args: - device: (device.Base) The Insteon device object to use for - sending messages. addr: (Address) The address of the device in the database. group: (int) The group the entry is for. is_controller: (bool) True if the device is a controller. @@ -375,7 +373,7 @@ def add_on_device(self, device, addr, group, is_controller, data, # those memory addresses and just update them w/ the correct # information and mark them as used. if add_unused: - self._add_using_unused(device, addr, group, is_controller, data, + self._add_using_unused(addr, group, is_controller, data, on_done, entry) # If there no unused entries, we need to append one. Write a new @@ -385,11 +383,10 @@ def add_on_device(self, device, addr, group, is_controller, data, # last entry anymore. This order is important since if either # operation fails, the db is still in a valid order. else: - self._add_using_new(device, addr, group, is_controller, data, - on_done) + self._add_using_new(addr, group, is_controller, data, on_done) #----------------------------------------------------------------------- - def delete_on_device(self, device, entry, on_done=None): + def delete_on_device(self, entry, on_done=None): """Delete an entry on the Insteon device. This sends the deletes the input record from the Insteon device. If @@ -406,8 +403,6 @@ def delete_on_device(self, device, entry, on_done=None): on_done( success, message, DeviceEntry ) Args: - device: (device.Base) The Insteon device object to use for - sending messages. entry: (DeviceEntry) The entry to remove. on_done: Optional callback which will be called when the command completes. @@ -420,7 +415,7 @@ def delete_on_device(self, device, entry, on_done=None): new_entry.db_flags.in_use = False if self.engine == 0: - modify_manager = DeviceModifyManagerI1(device, self, + modify_manager = DeviceModifyManagerI1(self.device, self, new_entry, on_done=on_done, num_retry=3) modify_manager.start_modify() @@ -433,7 +428,7 @@ def delete_on_device(self, device, entry, on_done=None): msg_handler = handler.DeviceDbModify(self, new_entry, on_done) # Send the message. - device.send(msg, msg_handler) + self.device.send(msg, msg_handler) #----------------------------------------------------------------------- def find_group(self, group): @@ -606,26 +601,6 @@ def diff(self, rhs): return delta - #----------------------------------------------------------------------- - def apply_diff(self, device, diff, on_done=None): - """TODO: doc - """ - assert self.addr == diff.addr - - seq = CommandSeq(device, "Device database sync complete", on_done) - - # Start by removing all the entries we don't need. This way we free - # up memory locations to use for the add. - for entry in diff.del_entries: - seq.add(self.delete_on_device, entry) - - # Add the missing entries. - for entry in diff.add_entries: - seq.add(self.add_on_device, device, entry.addr, entry.group, - entry.is_controller, entry.data) - - seq.run() - #----------------------------------------------------------------------- def to_json(self): """Convert the database to JSON format. @@ -761,7 +736,7 @@ def add_from_config(self, remote, local): self.add_entry(entry, save=False) #----------------------------------------------------------------------- - def _add_using_unused(self, device, addr, group, is_controller, data, + def _add_using_unused(self, addr, group, is_controller, data, on_done, entry=None): """Add an entry using an existing, unused entry. @@ -778,7 +753,7 @@ def _add_using_unused(self, device, addr, group, is_controller, data, entry.update_from(addr, group, is_controller, data) if self.engine == 0: - modify_manager = DeviceModifyManagerI1(device, self, + modify_manager = DeviceModifyManagerI1(self.device, self, entry, on_done=on_done, num_retry=3) modify_manager.start_modify() @@ -790,10 +765,10 @@ def _add_using_unused(self, device, addr, group, is_controller, data, msg_handler = handler.DeviceDbModify(self, entry, on_done) # Send the message and handler. - device.send(msg, msg_handler) + self.device.send(msg, msg_handler) #----------------------------------------------------------------------- - def _add_using_new(self, device, addr, group, is_controller, data, + def _add_using_new(self, addr, group, is_controller, data, on_done): """Add a anew entry at the end of the database. @@ -809,7 +784,7 @@ def _add_using_new(self, device, addr, group, is_controller, data, LOG.info("Device %s appending new record at mem %#06x", self.addr, self.last.mem_loc) - seq = CommandSeq(device, "Device database update complete", on_done) + seq = CommandSeq(self.device, "Device database update complete", on_done) # Shift the current last record down 8 bytes. Make a copy - we'll # only update our member var if the write works. @@ -820,7 +795,7 @@ def _add_using_new(self, device, addr, group, is_controller, data, # try and update w/ the new data record. if self.engine == 0: # on_done is passed by the sequence manager inside seq.add() - modify_manager = DeviceModifyManagerI1(device, self, + modify_manager = DeviceModifyManagerI1(self.device, self, last, on_done=None, num_retry=3) seq.add(modify_manager.start_modify) @@ -837,7 +812,7 @@ def _add_using_new(self, device, addr, group, is_controller, data, if self.engine == 0: # on_done is passed by the sequence manager inside seq.add() - modify_manager = DeviceModifyManagerI1(device, self, + modify_manager = DeviceModifyManagerI1(self.device, self, entry, on_done=None, num_retry=3) seq.add(modify_manager.start_modify) diff --git a/insteon_mqtt/db/Modem.py b/insteon_mqtt/db/Modem.py index 40eaccd2..64e04ac5 100644 --- a/insteon_mqtt/db/Modem.py +++ b/insteon_mqtt/db/Modem.py @@ -13,7 +13,6 @@ from .. import util from .ModemEntry import ModemEntry from .DbDiff import DbDiff -from ..CommandSeq import CommandSeq LOG = log.get_logger() @@ -268,7 +267,7 @@ def find_all(self, addr=None, group=None, is_controller=None): return results #----------------------------------------------------------------------- - def add_on_device(self, protocol, entry, on_done=None): + def add_on_device(self, entry, on_done=None): """Add an entry and push the entry to the Insteon modem. This sends the input record to the Insteon modem. If that command @@ -283,8 +282,6 @@ def add_on_device(self, protocol, entry, on_done=None): If the entry already exists, nothing will be done. Args: - protocol: (Protocol) The Insteon protocol object to use for - sending messages. entry: (ModemEntry) The entry to add. on_done: Optional callback which will be called when the command completes. @@ -318,10 +315,10 @@ def add_on_device(self, protocol, entry, on_done=None): msg_handler = handler.ModemDbModify(self, entry, exists, on_done) # Send the message. - protocol.send(msg, msg_handler) + self.device.send(msg, msg_handler) #----------------------------------------------------------------------- - def delete_on_device(self, protocol, entry, on_done=None): + def delete_on_device(self, entry, on_done=None): """Delete a series of entries on the device. This will delete ALL the entries for an address and group. The modem @@ -336,8 +333,6 @@ def delete_on_device(self, protocol, entry, on_done=None): on_done( success, message, ModemEntry ) Args: - protocol: (Protocol) The Insteon protocol object to use for - sending messages. addr: (Address) The address to delete. group: (int) The group to delete. on_done: Optional callback which will be called when the @@ -404,7 +399,7 @@ def delete_on_device(self, protocol, entry, on_done=None): # Send the first message. If it ACK's, it will keep sending more # deletes - one per entry. - protocol.send(msg, msg_handler) + self.device.send(msg, msg_handler) #----------------------------------------------------------------------- def diff(self, rhs): @@ -485,24 +480,6 @@ def diff(self, rhs): return delta - #----------------------------------------------------------------------- - def apply_diff(self, device, diff, on_done=None): - """TODO: doc - """ - assert diff.addr is None # Modem db doesn't have address - - seq = CommandSeq(device, "Modem database sync complete", on_done) - - # Start by removing all the entries we don't need. - for entry in diff.del_entries: - seq.add(self.delete_on_device, device.protocol, entry) - - # Add the missing entries. - for entry in diff.add_entries: - seq.add(self.add_on_device, device.protocol, entry) - - seq.run() - #----------------------------------------------------------------------- def to_json(self): """Convert the database to JSON format. diff --git a/insteon_mqtt/device/Base.py b/insteon_mqtt/device/Base.py index 0c92b3dd..cae31418 100644 --- a/insteon_mqtt/device/Base.py +++ b/insteon_mqtt/device/Base.py @@ -268,7 +268,7 @@ def join(self, on_done=None): LOG.info("Join Device %s", self.addr) # Using a sequence so we can pass the on_done function through. - seq = CommandSeq(self.protocol, "Operation Complete", on_done) + seq = CommandSeq(self, "Operation Complete", on_done) # First get the engine version. This process only works and is # necessary on I2CS devices. @@ -299,7 +299,7 @@ def _join_device(self, on_done=None): return else: # Build a sequence of calls to do the link. - seq = CommandSeq(self.protocol, "Operation Complete", on_done) + seq = CommandSeq(self, "Operation Complete", on_done) # Put Modem in linking mode first seq.add(self.modem.linking) @@ -377,7 +377,7 @@ def refresh(self, force=False, on_done=None): LOG.info("Device %s cmd: status refresh", self.label) # Use a sequence - seq = CommandSeq(self.protocol, "Device refreshed", on_done) + seq = CommandSeq(self, "Device refreshed", on_done) # This sends a refresh ping which will respond w/ the current # database delta field. The handler checks that against the @@ -514,7 +514,7 @@ def sync(self, dry_run=True, refresh=True, sequence=None, on_done=None): if sequence is not None: seq = sequence else: - seq = CommandSeq(self.protocol, "Sync complete", on_done, + seq = CommandSeq(self, "Sync complete", on_done, error_stop=False) if refresh: @@ -550,7 +550,7 @@ def _sync_del(self, entry, dry_run, on_done=None): on_done(True, None, None) else: LOG.ui(" Deleting %s:", entry) - self.db.delete_on_device(self, entry, on_done=on_done) + self.db.delete_on_device(entry, on_done=on_done) #----------------------------------------------------------------------- def _sync_add(self, entry, dry_run, on_done=None): @@ -563,7 +563,7 @@ def _sync_add(self, entry, dry_run, on_done=None): on_done(True, None, None) else: LOG.ui(" Adding %s:", entry) - self.db.add_on_device(self, entry.addr, entry.group, + self.db.add_on_device(entry.addr, entry.group, entry.is_controller, entry.data, on_done=on_done) @@ -1116,7 +1116,7 @@ def _db_update(self, local_group, is_controller, remote_addr, remote_group, "Link will be only one direction", util.ctrl_str(is_controller), remote_addr) - seq = CommandSeq(self.protocol, "Device db update complete", on_done) + seq = CommandSeq(self, "Device db update complete", on_done) # Check for a db update - otherwise we could be out of date and not # know it in which case the memory addresses to add the record in @@ -1135,7 +1135,7 @@ def _db_update(self, local_group, is_controller, remote_addr, remote_group, db_group = remote_group # Create a new database entry for the device and send it. - seq.add(self.db.add_on_device, self, remote_addr, db_group, + seq.add(self.db.add_on_device, remote_addr, db_group, is_controller, local_data) # For two way commands, insert a callback so that when the modem @@ -1180,7 +1180,7 @@ def _db_delete(self, addr, group, is_controller, two_way, refresh, on_done(False, "Entry doesn't exist", None) return - seq = CommandSeq(self.protocol, "Delete complete", on_done) + seq = CommandSeq(self, "Delete complete", on_done) # Check for a db update - otherwise we could be out of date and not # know it in which case the memory addresses to add the record in @@ -1188,7 +1188,7 @@ def _db_delete(self, addr, group, is_controller, two_way, refresh, if refresh: seq.add(self.refresh) - seq.add(self.db.delete_on_device, self, entry) + seq.add(self.db.delete_on_device, entry) # For two way commands, insert a callback so that when the modem # command finishes, it will send the next command to the device. diff --git a/insteon_mqtt/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index c01f58bd..4ba44fae 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -3,9 +3,12 @@ # Insteon battery powered motion sensor # #=========================================================================== +import time from .Base import Base from ..CommandSeq import CommandSeq from .. import log +from .. import on_off +from .. import message as Msg from ..Signal import Signal LOG = log.get_logger() @@ -17,7 +20,8 @@ class BatterySensor(Base): Battery powered sensors send basic on/off commands, low battery warnings, and hearbeat messages (some devices). This includes things like door sensors, hidden door sensors, and window sensors. This class also serves - as the base class for other battery sensors like motion sensors. + as the base class for other battery sensors like motion sensors, leak + sensors, remotes, and in the future others. The issue with a battery powered sensor is that we can't download the link database without the sensor being on. You can trigger the sensor @@ -66,6 +70,10 @@ def __init__(self, protocol, modem, address, name=None): # Sensor heartbeat signal. API: func( Device, True ) self.signal_heartbeat = Signal() + # Capture write messages as they FINISHED so we can pop the next + # message off the _send_queue + self.protocol.signal_msg_finished.connect(self.handle_finished) + # Derived classes can override these or add to them. Maps Insteon # groups to message type for this sensor. self.group_map = { @@ -78,6 +86,44 @@ def __init__(self, protocol, modem, address, name=None): } self._is_on = False + self._send_queue = [] + self.cmd_map.update({ + 'awake' : self.awake + }) + self._awake_time = False + + #----------------------------------------------------------------------- + def send(self, msg, msg_handler, high_priority=False, after=None): + """Send a message to the device. + + This captures and queues messages so that they can be sent when the + device is awake. Battery powered sensors listen for messages + for a brief period after sending messages. + + Args: + msg (Message): Output message to write. This should be an + instance of a message in the message directory that that starts + with 'Out'. + msg_handler (MsgHander): Message handler instance to use when + replies to the message are received. Any message + received after we write out the msg are passed to this + handler until the handler returns the message.FINISHED + flags. + high_priority (bool): False to add the message at the end of the + queue. True to insert this message at the start of + the queue. + after (float): Unix clock time tag to send the message after. + If None, the message is sent as soon as possible. Exact time + is not guaranteed - the message will be send no earlier than + this. + """ + # It seems like pressing the set button seems to keep them awake for + # about 3 minutes + if self._awake_time >= (time.time() - 180): + super().send(msg, msg_handler, high_priority, after) + else: + LOG.ui("BatterySensor %s - queueing msg until awake", self.label) + self._send_queue.append([msg, msg_handler, high_priority, after]) #----------------------------------------------------------------------- def pair(self, on_done=None): @@ -100,7 +146,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "BatterySensor paired", on_done) + seq = CommandSeq(self, "BatterySensor paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -129,6 +175,29 @@ def is_on(self): """ return self._is_on + #----------------------------------------------------------------------- + def handle_finished(self, msg): + """Handle write messages that are marked FINISHED + + All FINISHED msgs are emitted here are, NOT just those from this + device. + + This is used to pop a message off the _send_queue when the prior + message FINISHES. Notably messages that expire do not appear here + but NAK messages will still appear here (which is fine, NAK means + it is still awake). + + Args: + msg (msg): A write message that was marked msg.FINISHED + """ + # Ignore modem messages, broadcast messages, only look for + # communications from the device + if isinstance(msg, (Msg.InpStandard, Msg.InpExtended)): + # Is this a message from this device? + if msg.from_addr == self.addr: + # Pop messages from _send_queue if necessary + self._pop_send_queue() + #----------------------------------------------------------------------- def handle_broadcast(self, msg): """Handle broadcast messages from this device. @@ -146,6 +215,9 @@ def handle_broadcast(self, msg): states (Insteon devices don't send out a state change when the respond to a broadcast). + Finally, if any messages are in self._send_queue, pop a message and + send it while the device is still awake. + Args: msg (InpStandard): Broadcast message from the device. """ @@ -154,8 +226,9 @@ def handle_broadcast(self, msg): LOG.info("BatterySensor %s broadcast ACK grp: %s", self.addr, msg.group) - # On (0x11) and off (0x13) commands. - elif msg.cmd1 == 0x11 or msg.cmd1 == 0x13: + # Valid command + elif (on_off.Mode.is_valid(msg.cmd1) or + on_off.Manual.is_valid(msg.cmd1)): LOG.info("BatterySensor %s broadcast cmd %s grp: %s", self.addr, msg.cmd1, msg.group) @@ -172,12 +245,8 @@ def handle_broadcast(self, msg): # (without sending anything out). super().handle_broadcast(msg) - # If we haven't downloaded the device db yet, use this opportunity to - # get the device db since we know the sensor is awake. This doesn't - # always seem to work, but it works often enough to be useful to try. - if len(self.db) == 0: - LOG.info("BatterySensor %s awake - requesting database", self.addr) - self.refresh(force=True) + # Pop messages from _send_queue if necessary + self._pop_send_queue() #----------------------------------------------------------------------- def handle_on_off(self, msg): @@ -237,6 +306,31 @@ def handle_refresh(self, msg): # to match. self._set_is_on(msg.cmd2 != 0x00) + #----------------------------------------------------------------------- + def awake(self, on_done): + """Set the device as awake. + + This will mark the device as awake and ready to receive commands. + Normally battery devices are deaf only only listen for commands + briefly after they wake up. But you can manually wake them up by + holding down their set button and putting them into linking mode. + They will generally remain awake for about 3 minutes. + + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.ui("BatterySensor %s marked as awake", self.label) + + # Update the awake time to be now + self._awake_time = time.time() + + # Dump all messages in the queue + for args in self._send_queue: + super().send(*args) + #Empty the queue + self._send_queue = [] + on_done(True, "Complete", None) + #----------------------------------------------------------------------- def _set_is_on(self, is_on): """Set the device on/off state. @@ -252,3 +346,17 @@ def _set_is_on(self, is_on): self.signal_on_off.emit(self, self._is_on) #----------------------------------------------------------------------- + def _pop_send_queue(self): + """Pops a messages off the _send_queue if necessary + + If we have any messages in the _send_queue, now is the time to send + them while the device is awake, unless a message for this device is + already pending in the protocol write queue + """ + if (self._send_queue and + not self.protocol.is_addr_in_write_queue(self.addr)): + LOG.info("BatterySensor %s awake - sending msg", self.label) + args = self._send_queue.pop() + super().send(*args) + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/Dimmer.py b/insteon_mqtt/device/Dimmer.py index 5287a3fe..a47dc2bf 100644 --- a/insteon_mqtt/device/Dimmer.py +++ b/insteon_mqtt/device/Dimmer.py @@ -106,7 +106,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "Dimmer paired", on_done) + seq = CommandSeq(self, "Dimmer paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -552,7 +552,7 @@ def set_flags(self, on_done, **kwargs): "are: %s" % unknown, flags) # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self.protocol, "Dimmer set_flags complete", on_done) + seq = CommandSeq(self, "Dimmer set_flags complete", on_done) if FLAG_BACKLIGHT in kwargs: backlight = util.input_byte(kwargs, FLAG_BACKLIGHT) diff --git a/insteon_mqtt/device/FanLinc.py b/insteon_mqtt/device/FanLinc.py index 8a85a7de..e2567a38 100644 --- a/insteon_mqtt/device/FanLinc.py +++ b/insteon_mqtt/device/FanLinc.py @@ -101,7 +101,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "FanLinc paired", on_done) + seq = CommandSeq(self, "FanLinc paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -147,7 +147,7 @@ def refresh(self, force=False, on_done=None): """ LOG.info("Device %s cmd: fan status refresh", self.addr) - seq = CommandSeq(self.protocol, "Refresh complete", on_done) + seq = CommandSeq(self, "Refresh complete", on_done) # Send a 0x19 0x03 command to get the fan speed level. This sends a # refresh ping which will respond w/ the fan level and current diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index e4e52593..0254ae15 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -3,12 +3,14 @@ # Insteon on/off device # #=========================================================================== -import functools +import enum +import time from .Base import Base from ..CommandSeq import CommandSeq from .. import handler from .. import log from .. import message as Msg +from .. import on_off from ..Signal import Signal from .. import util @@ -25,112 +27,92 @@ class IOLinc(Base): number of internal mode state which change how the device behaves (see below for details). + NOTE: DO NOT USE THE SET BUTTON ON THE DEVICE TO CONTROL THE DEVICE. This + will confuse the code and will cause the representation of the sensor + and relay states to get our of whack. It will also cause devices which are + linked to the sensor to react when in fact the sensor has not tripped. + This can be fixed by running refresh which always updates both the sensor + and relay. + State changes are communicated by emitting signals. Other classes can connect to these signals to perform an action when a change is made to the device (like sending MQTT messages). Supported signals are: - - signal_on_off( Device, bool is_on, on_off.Mode mode ): + - signal_on_off( Device, bool sensor_is_on, bool relay_is_on, + on_off.Mode mode ): Sent whenever the sensor is turned on or off. - - signal_manual( Device, on_off.Manual mode ): Sent when the device - starts or stops manual mode (when a button is held down or released). - NOTES: - Link the relay as responder to another device (controller). Acttivate - the controller. Relay updates but state update is NOT emitted. - - Latching mode: - - Click set button by hand. Toggles relay on or off. But it also - emits a status update for the relay, not the sensor. [BAD] - - - Send on command. Relay turns on. No state update. [GOOD] - - Send off command. Relay turns on. No state update. [GOOD] - - - Send scene simulation on command. Relay turns on. Status update - with ON payload sent. [BAD] - - Send scene simulation Off command. Relay turns off. Status update - with OFF payload sent. [BAD] - - - Click controller button (or send modem scene) on. Relay turns on. - No state update. [GOOD] - - Click controller button (or send modem scene) off. Relay turns - off. No state update. [GOOD] - - Momemtary A: - ON turns relay on, then off after delay. OFF is ignored. (this can be - reversed - depends on the state of the relay when paired - i.e D1 in - the link db responder line) - - - Click set button by hand. Relay turns on, then off. Emits a state - ON update but no OFF update. [BAD] - - - Send on. Relay turns on, then off after delay. No state - update. [GOOD] - - Send off. Relay does nothing. No state update. [GOOD] - - - Send scene simulation on command. Relay turns on, then off after - delay. Status update with ON payload sent. [BAD] - - Send scene simulation Off command. Nothing happens. - - - Click controller button (or send modem scene) on. Relay turns on, - then off after delay. No state update. [GOOD] - - Click controller button (or send modem scene) off. Nothing happens. - - Momentary B: - ON or OFF turns relay on, then off after delay. - - - Click set button by hand. Relay turns on, then off after delay. - Emits a state ON update but no OFF update. [BAD] - - - Send on. Relay turns on, then off after delay. No state - update. [GOOD] - - Send off. Relay does nothing. No state update. [GOOD] - - - Send scene simulation on command. Relay turns on, then off after - delay. Status update with ON payload sent. [BAD] - - Send scene simulation Off command. Relay turns on, then off after - delay. Status update with OFF payload sent. [BAD] - - - Click controller button (or send modem scene) on. Relay turns on, - then off after delay. No state update. [GOOD] - - Click controller button (or send modem scene) off. Relay turns on, - then off after delay. No state update. [GOOD] - - Momentary C: - ON turns relay on only if sensor is in correct state (state of device - when linked - i.e. D1 in the link db responder line). - - - Click set button by hand. Relay turns on, then off after delay. - Emits a state ON update but no OFF update. [BAD] - - - Send on. Relay turns on, then off after delay. No state - update. [GOOD] - But this ignores the sensor value [BAD?] - - Send off. Relay does nothing. No state update. [GOOD] - - - Send scene simulation on command. Relay turns on, then off after - delay. Status update with ON payload sent. Ignores sensor value - [BAD] - - Send scene simulation Off command. Relay turns on, then off after - delay. Status update with ON payload sent. Ignores sensor value - [BAD] - - - Click controller button (or send modem scene) on. Relay turns on, - then off after delay. No state update. [GOOD] Sensor value is - handled correctly. - - Click controller button (or send modem scene) off. Relay turns on, - then off after delay. No state update. [GOOD] Sensor value is - handled correctly. - - Not about Momentary C: If you click a keypadlinc button, it will toggle. - The IOLinc may not do anything though - i.e. if the sensor is already in - that state, it won't fire. But the keypad button has toggled so now it's - LED on/off is wrong. Might be some way to "fix" this but it's not - obvious whether or not it's a good idea or not. Might be nice to have an - option to FORCE a controller of the IO linc to always be in the correct - state to show the door open or closed. + - Broadcast messages from the device always* describe the state of the + device sensor. + - Commands sent to the device always affect the state of the relay. + - Using the On/Off/Set commands will always cause the relay to change + to the requested state. The device will ignore any Momentary_A or + Momentary_C requirements about the type of command or the state of the + sensor. Similarly the relay will still trip even if relay_linked is + enabled and the sensor has not tripped. The momentary_secs length is + still respected and the relay will return to the off position after + the requisite length of time. The code will accururately track the + state of the relay and sensor following these commands. + - Controlling the IOLinc from another device or a modem scene works + as Insteon intended. + + Note about Momentary_C: + If the IOLinc is in Momentary_C mode and a command + is sent that does not match the requested sensor state, the relay will + not trigger. The code handles this accurately, but a physical device + will not know the difference. So, if you click a keypadlinc button, it + will toggle. The IOLinc may not do anything though - i.e. if the sensor + is already in that state, it won't fire. But the keypad button has + toggled so now it's LED on/off is wrong. Might be some way to "fix" + this but it's not obvious whether or not it's a good idea or not. + Might be nice to have an option to FORCE a controller of the IO linc + to always be in the correct state to show the door open or closed. + + * Gotchas: + - Clicking the set button on the device always causes the relay to trip + and sends a broadcast message containing the state of the relay. From + the code, this will appear as a change in the sensor and will be + recorded as such. + - The simulated scene function causes basically the same result. The + relay will respond to the command, but the device will also send out + a broadcast message that appears as though it is a change in the sensor + state. As such, scene() is not enabled for this device. """ type_name = "io_linc" + # Map of operating flag values that can be directly set. Details can + # be found in document titled 'IOLinc Datasheet' + class OperatingFlags(enum.IntEnum): + PROGRAM_LOCK_ON = 0x00 + PROGRAM_LOCK_OFF = 0x01 + LED_ON_DURING_TX = 0x02 + LED_OFF_DURING_TX = 0x03 + RELAY_FOLLOWS_INPUT_ON = 0x04 + RELAY_FOLLOWS_INPUT_OFF = 0x05 + MOMENTARY_A_ON = 0x06 + MOMENTARY_A_OFF = 0x07 + LED_OFF = 0x08 + LED_ENABLED = 0x09 + KEY_BEEP_ENABLED = 0x0a + KEY_BEEP_OFF = 0x0b + X10_TX_ON_WHEN_OFF = 0x0c + X10_TX_ON_WHEN_ON = 0x0d + INVERT_SENSOR_ON = 0x0e + INVERT_SENSOR_OFF = 0x0f + X10_RX_ON_IS_OFF = 0x10 + X10_RX_ON_IS_ON = 0x11 + MOMENTARY_B_ON = 0x12 + MOMENTARY_B_OFF = 0x13 + MOMENTARY_C_ON = 0x14 + MOMENTARY_C_OFF = 0x15 + + class Modes(enum.IntEnum): + LATCHING = 0x00 + MOMENTARY_A = 0x01 + MOMENTARY_B = 0x02 + MOMENTARY_C = 0x03 + def __init__(self, protocol, modem, address, name=None): """Constructor @@ -144,30 +126,143 @@ def __init__(self, protocol, modem, address, name=None): """ super().__init__(protocol, modem, address, name) - self._is_on = False + # Used to track the state of sensor and relay + self._sensor_is_on = False + self._relay_is_on = False + + # Used to track the momentary_call that will automatically turn off + # the relay + self._momentary_call = None # Support on/off style signals for the sensor # API: func(Device, bool is_on) self.signal_on_off = Signal() - # Group number of the virtual modem scene linked to this device. We - # need this so we can trigger the device and have it respond using - # the correct mode information. If we used a direct command, it will - # just turn on/off the relay and not use the various latching and - # momentary configurations. So instead of sending a direct on/off, - # we'll trigger this virtual scene. - self.modem_scene = None - # Remote (mqtt) commands mapped to methods calls. Add to the # base class defined commands. self.cmd_map.update({ 'on' : self.on, 'off' : self.off, 'set' : self.set, - 'scene' : self.scene, 'set_flags' : self.set_flags, }) + #----------------------------------------------------------------------- + @property + def mode(self): + """Returns the mode from the saved metadata + """ + meta = self.db.get_meta('IOLinc') + ret = IOLinc.Modes.LATCHING + if isinstance(meta, dict) and 'mode' in meta: + try: + ret = IOLinc.Modes(meta['mode']) + except ValueError: + # Somehow we saved a value that doesn't exist + pass + return ret + + #----------------------------------------------------------------------- + @mode.setter + def mode(self, val): + """Saves mode to the database metadata + + Args: + val: (IOLinc.Modes) + """ + if val in IOLinc.Modes: + meta = {'mode': val.value} + existing = self.db.get_meta('IOLinc') + if isinstance(existing, dict): + existing.update(meta) + self.db.set_meta('IOLinc', existing) + else: + self.db.set_meta('IOLinc', meta) + else: + LOG.error("Bad value %s, for mode on IOLinc %s.", val, + self.addr) + + #----------------------------------------------------------------------- + @property + def trigger_reverse(self): + """Returns the trigger_reverse state from the saved metadata + """ + meta = self.db.get_meta('IOLinc') + ret = False + if isinstance(meta, dict) and 'trigger_reverse' in meta: + ret = meta['trigger_reverse'] + return ret + + #----------------------------------------------------------------------- + @trigger_reverse.setter + def trigger_reverse(self, val): + """Saves trigger_reverse state to the database metadata + + Args: + val: (bool) + """ + meta = {'trigger_reverse': val} + existing = self.db.get_meta('IOLinc') + if isinstance(existing, dict): + existing.update(meta) + self.db.set_meta('IOLinc', existing) + else: + self.db.set_meta('IOLinc', meta) + + #----------------------------------------------------------------------- + @property + def relay_linked(self): + """Returns the relay_linked state from the saved metadata + """ + meta = self.db.get_meta('IOLinc') + ret = False + if isinstance(meta, dict) and 'relay_linked' in meta: + ret = meta['relay_linked'] + return ret + + #----------------------------------------------------------------------- + @relay_linked.setter + def relay_linked(self, val): + """Saves relay_linked state to the database metadata + + Args: + val: (bool) + """ + meta = {'relay_linked': val} + existing = self.db.get_meta('IOLinc') + if isinstance(existing, dict): + existing.update(meta) + self.db.set_meta('IOLinc', existing) + else: + self.db.set_meta('IOLinc', meta) + + #----------------------------------------------------------------------- + @property + def momentary_secs(self): + """Returns the momentary seconds from the saved metadata + """ + meta = self.db.get_meta('IOLinc') + ret = 2.0 # the default on the device is 2.0 seconds + if isinstance(meta, dict) and 'momentary_secs' in meta: + ret = meta['momentary_secs'] + return ret + + #----------------------------------------------------------------------- + @momentary_secs.setter + def momentary_secs(self, val): + """Saves momentary seconds to the database metadata + + Args: + val: (float) .1 - 6300.0 + """ + meta = {'momentary_secs': val} + existing = self.db.get_meta('IOLinc') + if isinstance(existing, dict): + existing.update(meta) + self.db.set_meta('IOLinc', existing) + else: + self.db.set_meta('IOLinc', meta) + #----------------------------------------------------------------------- def pair(self, on_done=None): """Pair the device with the modem. @@ -189,7 +284,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "IOLinc paired", on_done) + seq = CommandSeq(self, "IOLinc paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -208,21 +303,45 @@ def pair(self, on_done=None): seq.add(self.db_add_ctrl_of, 0x01, self.modem.addr, 0x01, refresh=False) - # We need to create a virtual modem scene to trigger the IOLinc - # properly so find an unused group number. - group = self.modem.db.next_group() - if group is not None: - seq.add(self.db_add_resp_of, 0x01, self.modem.addr, group, - refresh=False) - else: - LOG.error("Can't create IOLinc simulated scene - there are no " - "unused modem scene numbers available") - # Finally start the sequence running. This will return so the # network event loop can process everything and the on_done callbacks # will chain everything together. seq.run() + #----------------------------------------------------------------------- + def get_flags(self, on_done=None): + + """Get the Insteon operational flags field from the device. + + The flags will be passed to the on_done callback as the data field. + Derived types may do something with the flags by override the + handle_flags method. + + Args: + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("IOLinc %s cmd: get operation flags", self.label) + + seq = CommandSeq(self.protocol, "IOlinc get flags done", on_done) + + # This sends a refresh ping which will respond w/ the current + # database delta field. The handler checks that against the + # current value. If it's different, it will send a database + # download command to the device to update the database. + msg = Msg.OutStandard.direct(self.addr, 0x1f, 0x00) + msg_handler = handler.StandardCmd(msg, self.handle_flags, on_done) + seq.add_msg(msg, msg_handler) + + # Get the momentary time value + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, + bytes([0x00] * 14)) + msg_handler = handler.ExtendedCmdResponse(msg, + self.handle_get_momentary) + seq.add_msg(msg, msg_handler) + + seq.run() + #----------------------------------------------------------------------- def set_flags(self, on_done, **kwargs): """Set internal device flags. @@ -248,71 +367,114 @@ def set_flags(self, on_done, **kwargs): # Check the input flags to make sure only ones we can understand were # passed in. - flags = set(["mode", "trigger_reverse", "relay_linked"]) + flags = set(["mode", "trigger_reverse", "relay_linked", + "momentary_secs"]) unknown = set(kwargs.keys()).difference(flags) if unknown: - raise Exception("Unknown IOLinc flags input: %s.\n Valid flags " + raise Exception("Unknown IOLinc flags input: %s.\n Valid flags " + "are: %s" % unknown, flags) - # We need the existing bit set before we change it. So to insure - # that we are starting from the correct values, get the current bits - # and pass that to the callback which will update them to make the - # changes. - callback = functools.partial(self._change_flags, kwargs=kwargs, - on_done=util.make_callback(on_done)) - self.get_flags(on_done=callback) + seq = CommandSeq(self.protocol, "Device flags set", on_done) + + # Loop through flags, sending appropriate command for each flag + for flag in kwargs: + if flag == 'mode': + try: + mode = IOLinc.Modes[kwargs[flag].upper()] + except KeyError: + mode = IOLinc.Modes.LATCHING + # Save this to the device metadata + self.mode = mode + if mode == IOLinc.Modes.LATCHING: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_OFF + type_b = IOLinc.OperatingFlags.MOMENTARY_B_OFF + type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF + elif mode == IOLinc.Modes.MOMENTARY_A: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON + type_b = IOLinc.OperatingFlags.MOMENTARY_B_OFF + type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF + elif mode == IOLinc.Modes.MOMENTARY_B: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON + type_b = IOLinc.OperatingFlags.MOMENTARY_B_ON + type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF + elif mode == IOLinc.Modes.MOMENTARY_C: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON + type_b = IOLinc.OperatingFlags.MOMENTARY_B_ON + type_c = IOLinc.OperatingFlags.MOMENTARY_C_ON + for cmd2 in (type_a, type_b, type_c): + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, + bytes([0x00] * 14)) + msg_handler = handler.StandardCmd(msg, + self.handle_set_flags) + seq.add_msg(msg, msg_handler) + + elif flag == 'trigger_reverse': + if util.input_bool(kwargs.copy(), "trigger_reverse"): + # Save this to the device metadata + self.trigger_reverse = True + cmd2 = IOLinc.OperatingFlags.INVERT_SENSOR_ON + else: + # Save this to the device metadata + self.trigger_reverse = False + cmd2 = IOLinc.OperatingFlags.INVERT_SENSOR_OFF + + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, + bytes([0x00] * 14)) + msg_handler = handler.StandardCmd(msg, self.handle_set_flags) + seq.add_msg(msg, msg_handler) + + elif flag == 'relay_linked': + if util.input_bool(kwargs.copy(), "relay_linked"): + # Save this to the device metadata + self.relay_linked = True + cmd2 = IOLinc.OperatingFlags.RELAY_FOLLOWS_INPUT_ON + else: + # Save this to the device metadata + self.relay_linked = False + cmd2 = IOLinc.OperatingFlags.RELAY_FOLLOWS_INPUT_OFF + + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, + bytes([0x00] * 14)) + msg_handler = handler.StandardCmd(msg, self.handle_set_flags) + seq.add_msg(msg, msg_handler) + + elif flag == 'momentary_secs': + # IOLinc allows setting the momentary time between 0.1 and + # 6300 seconds. At the low end with a resolution of .1 of a + # second. To store the higher numbers, a multiplier is used + # the multiplier as used by the insteon app has discrete steps + # 1, 10, 100, 200, and 250. No other steps are used. + dec_seconds = int(float(kwargs[flag]) * 10) + multiple = 0x01 + if dec_seconds > 51000: + multiple = 0xfa + elif dec_seconds > 25500: + multiple = 0xc8 + elif dec_seconds > 2550: + multiple = 0x64 + elif dec_seconds > 255: + multiple = 0x0a + + time_val = int(dec_seconds / multiple) + # Set the time value + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, + bytes([0x00, 0x06, time_val] + + [0x00] * 11)) + msg_handler = handler.StandardCmd(msg, self.handle_set_flags) + seq.add_msg(msg, msg_handler) + + # set the multiple + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, + bytes([0x00, 0x07, multiple, ] + + [0x00] * 11)) + msg_handler = handler.StandardCmd(msg, self.handle_set_flags) + seq.add_msg(msg, msg_handler) + + # Save this to the device metadata + self.momentary_secs = (dec_seconds * multiple) / 10 - # FUTURE:momentary_time: extended set command: cmd: 0x2e 0x00 - # D3 = 0x06 - # D4 = momentary time in 0.10 sec: 0x02 -> 0xff - # FUTURE: led backlight - - #----------------------------------------------------------------------- - def _change_flags(self, success, msg, bits, kwargs, on_done): - """Change the operating flags. - - See the set_flags() code for details. - """ - if not success: - on_done(success, msg, None) - return - - # Mode might be None in which case it wasn't input. - choices = ["latching", "momentary-a", "momentary-b", "momentary-c"] - mode = util.input_choice(kwargs, "mode", choices) - - if mode == "latching": - bits = util.bit_set(bits, 3, 0) - bits = util.bit_set(bits, 4, 0) - bits = util.bit_set(bits, 7, 0) - elif mode == "momentary-a": - bits = util.bit_set(bits, 3, 1) - bits = util.bit_set(bits, 4, 0) - bits = util.bit_set(bits, 7, 0) - elif mode == "momentary-b": - bits = util.bit_set(bits, 3, 1) - bits = util.bit_set(bits, 4, 1) - bits = util.bit_set(bits, 7, 0) - elif mode == "momentary-c": - bits = util.bit_set(bits, 3, 1) - bits = util.bit_set(bits, 4, 1) - bits = util.bit_set(bits, 7, 1) - - trigger_reverse = util.input_bool(kwargs, "trigger_reverse") - if trigger_reverse is not None: - bits = util.bit_set(bits, 6, trigger_reverse) - - relay_linked = util.input_bool(kwargs, "relay_linked") - if relay_linked is not None: - bits = util.bit_set(bits, 2, trigger_reverse) - - # This sends a refresh ping which will respond w/ the current - # database delta field. The handler checks that against the current - # value. If it's different, it will send a database download command - # to the device to update the database. - msg = Msg.OutStandard.direct(self.addr, 0x20, bits) - msg_handler = handler.StandardCmd(msg, self.handle_flags) - self.send(msg, msg_handler) + # Run all the commands. + seq.run() #----------------------------------------------------------------------- def refresh(self, force=False, on_done=None): @@ -326,6 +488,8 @@ def refresh(self, force=False, on_done=None): This will send out an updated signal for the current device status whenever possible (like dimmer levels). + This will update the state of both the sensor and the relay. + Args: force (bool): If true, will force a refresh of the device database even if the delta value matches as well as a re-query of the @@ -337,15 +501,23 @@ def refresh(self, force=False, on_done=None): # NOTE: IOLinc cmd1=0x00 will report the relay state. cmd2=0x01 # reports the sensor state which is what we want. - seq = CommandSeq(self.protocol, "Device refreshed", on_done) + seq = CommandSeq(self, "Device refreshed", on_done) # This sends a refresh ping which will respond w/ the current # database delta field. The handler checks that against the current # value. If it's different, it will send a database download command # to the device to update the database. + # This handles the relay state + msg = Msg.OutStandard.direct(self.addr, 0x19, 0x00) + msg_handler = handler.DeviceRefresh(self, self.handle_refresh_relay, + force, on_done, num_retry=3) + seq.add_msg(msg, msg_handler) + + # This Checks the sensor state, ignore force refresh here (we just did + # it above) msg = Msg.OutStandard.direct(self.addr, 0x19, 0x01) - msg_handler = handler.DeviceRefresh(self, self.handle_refresh, force, - on_done, num_retry=3) + msg_handler = handler.DeviceRefresh(self, self.handle_refresh_sensor, + False, on_done, num_retry=3) seq.add_msg(msg, msg_handler) # If model number is not known, or force true, run get_model @@ -355,22 +527,16 @@ def refresh(self, force=False, on_done=None): seq.run() #----------------------------------------------------------------------- - def is_on(self): - """Return if the device is on or not. - """ - return self._is_on - - #----------------------------------------------------------------------- - def on(self, group=0x01, level=None, instant=False, on_done=None): + def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", + on_done=None): """Turn the relay on. This turns the relay on no matter what. It ignores the momentary - A/B/C settings and just turns the relay on. - - NOTE: This does NOT simulate a button press on the device - it just - changes the state of the device. It will not trigger any responders - that are linked to this device. To simulate a button press, call the - scene() method. + A/B/C settings and just turns the relay on. It will not trigger any + responders that are linked to this device. If you want to control + the device where it respects the momentary settings and properly + updates responders, please define a scene for the device and use + that scene to control it. This will send the command to the device to update it's state. When we get an ACK of the result, we'll change our internal state and emit @@ -399,16 +565,16 @@ def on(self, group=0x01, level=None, instant=False, on_done=None): self.send(msg, msg_handler) #----------------------------------------------------------------------- - def off(self, group=0x01, instant=False, on_done=None): + def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", + on_done=None): """Turn the relay off. - This turns the relay on no matter what. It ignores the momentary - A/B/C settings and just turns the relay on. - - NOTE: This does NOT simulate a button press on the device - it just - changes the state of the device. It will not trigger any responders - that are linked to this device. To simulate a button press, call the - scene() method. + This turns the relay off no matter what. It ignores the momentary + A/B/C settings and just turns the relay off. It will not trigger any + responders that are linked to this device. If you want to control + the device where it respects the momentary settings and properly + updates responders, please define a scene for the device and use + that scene to control it. This will send the command to the device to update it's state. When we get an ACK of the result, we'll change our internal state and emit @@ -438,12 +604,11 @@ def set(self, level, group=0x01, instant=False, on_done=None): """Turn the relay on or off. Level zero will be off. This turns the relay on or off no matter what. It ignores the - momentary A/B/C settings and just turns the relay on. - - NOTE: This does NOT simulate a button press on the device - it just - changes the state of the device. It will not trigger any responders - that are linked to this device. To simulate a button press, call the - scene() method. + momentary A/B/C settings and just turns the relay on. It will not + trigger any responders that are linked to this device. If you want to + control the device where it respects the momentary settings and + properly updates responders, please define a scene for the device and + use that scene to control it. This will send the command to the device to update it's state. When we get an ACK of the result, we'll change our internal @@ -465,52 +630,18 @@ def set(self, level, group=0x01, instant=False, on_done=None): else: self.off(group, instant, on_done) - #----------------------------------------------------------------------- - def scene(self, is_on, group=None, on_done=None): - """Trigger a scene on the device. - - Triggering a scene is the same as simulating a button press on the - device. It will change the state of the device and notify responders - that are linked ot the device to be updated. - - Args: - is_on (bool): True for an on command, False for an off command. - group (int): The group on the device to simulate. For this device, - this must be 1. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - on_done = util.make_callback(on_done) - - # If we haven't found the virtual PLM scene yet, search for it now. - if self.modem_scene is None: - LOG.info("IOLinc %s finding correct modem scene to use", - self.label) - - entries = self.db.find_all(self.modem.addr, is_controller=False) - for e in entries: - if e.group != 0x01: - self.modem_scene = e.group - LOG.info("IOLinc %s found scene %s", self.label, e.group) - break - else: - LOG.error("Can't trigger IOLinc scene - there is no responder " - "from the modem in the IOLinc db") - on_done(False, "Failed to send scene command", None) - return - - # Tell the modem to send it's virtual scene broadcast to the IOLinc - # device. - LOG.info("IOLinc %s triggering modem scene %s", self.label, - self.modem_scene) - self.modem.scene(is_on, self.modem_scene, on_done=on_done) - #----------------------------------------------------------------------- def handle_broadcast(self, msg): """Handle broadcast messages from this device. The broadcast message from a device is sent when the device is - triggered. The message has the group ID in it. We'll update the + triggered. These messages always indicate the state of the sensor. + With ONE EXCEPTION, if you manually press the set button on the device + it will toggle the relay and send this message. This will cause the + sensor and relay states to be reported wrong. Just don't use the set + button. If you do, you can run refresh to fix the states. + + The message has the group ID in it. We'll update the device state and look up the group in the all link database. For each device that is in the group (as a reponsder), we'll call handle_group_cmd() on that device to trigger it. This way all the @@ -528,12 +659,18 @@ def handle_broadcast(self, msg): # On command. 0x11: on elif msg.cmd1 == 0x11: LOG.info("IOLinc %s broadcast ON grp: %s", self.addr, msg.group) - self._set_is_on(True) + self._set_sensor_is_on(True) + if self.relay_linked: + # If relay_linked is enabled then the relay was triggered + self._set_relay_is_on(True) # Off command. 0x13: off elif msg.cmd1 == 0x13: LOG.info("IOLinc %s broadcast OFF grp: %s", self.addr, msg.group) - self._set_is_on(False) + self._set_sensor_is_on(False) + if self.relay_linked: + # If relay_linked is enabled then the relay was triggered + self._set_relay_is_on(False) # This will find all the devices we're the controller of for # this group and call their handle_group_cmd() methods to @@ -543,9 +680,9 @@ def handle_broadcast(self, msg): #----------------------------------------------------------------------- def handle_flags(self, msg, on_done): - """Callback for handling flag change responses. + """Callback for handling get flag responses. - This is called when we get a response to the set_flags command. + This is called when we get a response to the get_flags command. Args: msg (message.InpStandard): The refresh message reply. The current @@ -579,92 +716,146 @@ def handle_flags(self, msg, on_done): LOG.ui("Program lock : %d", util.bit_get(bits, 0)) LOG.ui("Transmit LED : %d", util.bit_get(bits, 1)) LOG.ui("Relay linked : %d", util.bit_get(bits, 2)) + self.relay_linked = bool(util.bit_get(bits, 2)) LOG.ui("Trigger reverse: %d", util.bit_get(bits, 6)) + self.trigger_reverse = bool(util.bit_get(bits, 6)) if not util.bit_get(bits, 3): mode = "latching" elif util.bit_get(bits, 7): - mode = "momentary C" + mode = "momentary_C" elif util.bit_get(bits, 4): - mode = "momentary B" + mode = "momentary_B" else: - mode = "momentary A" + mode = "momentary_A" + + # Save mode to device metadata + self.mode = IOLinc.Modes[mode.upper()] - # In the future, we should store this information and do something - # with it. LOG.ui("Relay latching : %s", mode) on_done(True, "Operation complete", msg.cmd2) #----------------------------------------------------------------------- - def handle_refresh(self, msg): - """Callback for handling refresh() responses. + def handle_get_momentary(self, msg, on_done): + """Callback for handling get momemtary time responses. - This is called when we get a response to the refresh() command. The - refresh command reply will contain the current device state in cmd2 - and this updates the device with that value. It is called by + This is called when we get a response to the get_flags command. + + Args: + msg (message.InpExtended): The extended payload. The time + data is in D4. + """ + # Data Values are: + # If Data 2 = 1 Rx unit returned data with + # Data 3:Time multiple (1,10,100, or 250) + # Data 4:Closure time + # Data 5:X10 House code(20h = none) In + # Data 6: X10 Unit In + # Data 7:House Out + # Data 8: Unit Out + # Data 9: S/N + + # Valid momentary times are between .1 and 6300 seconds. There is + # finer resolution at the low end and not at much at the high end. + + seconds = (msg.data[3] * msg.data[2]) / 10 + self.momentary_secs = seconds + LOG.ui("Momentary Secs : %s", seconds) + on_done(True, "Operation complete", None) + + #----------------------------------------------------------------------- + def handle_set_flags(self, msg, on_done): + """Callback for handling flag change responses. + + This is called when we get a response to the set_flags command. + + Args: + msg (message.InpStandard): The refresh message reply. The msg.cmd2 + field represents the flag that was set. + """ + LOG.info("IOLinc Set Flag=%s", msg.cmd2) + on_done(True, "Operation complete", msg.cmd2) + + #----------------------------------------------------------------------- + def handle_refresh_relay(self, msg): + """Callback for handling refresh() responses for the relay + + This is called when we get a response to the first refresh() command. + The refresh command reply will contain the current device relay state + in cmd2 and this updates the device with that value. It is called by handler.DeviceRefresh when we can an ACK for the refresh command. Args: msg (message.InpStandard): The refresh message reply. The current - device state is in the msg.cmd2 field. + device relay state is in the msg.cmd2 field. + """ + LOG.ui("IOLinc %s refresh relay on=%s", self.label, msg.cmd2 > 0x00) + + # Current on/off level is stored in cmd2 so update our level to + # match. + self._set_relay_is_on(msg.cmd2 > 0x00) + + #----------------------------------------------------------------------- + def handle_refresh_sensor(self, msg): + """Callback for handling refresh() responses for the sensor. + + This is called when we get a response to the second refresh() command. + The refresh command reply will contain the current device sensor state + in cmd2 and this updates the device with that value. It is called by + handler.DeviceRefresh when we can an ACK for the refresh command. + + Args: + msg (message.InpStandard): The refresh message reply. The current + device sensor state is in the msg.cmd2 field. """ - LOG.ui("IOLinc %s refresh on=%s", self.label, msg.cmd2 > 0x00) + LOG.ui("IOLinc %s refresh sensor on=%s", self.label, msg.cmd2 > 0x00) # Current on/off level is stored in cmd2 so update our level to # match. - self._set_is_on(msg.cmd2 > 0x00) + self._set_sensor_is_on(msg.cmd2 > 0x00) #----------------------------------------------------------------------- def handle_ack(self, msg, on_done): """Callback for standard commanded messages. - This callback is run when we get a reply back from one of our + This callback is run when we get a reply back from one of our direct commands to the device. If the command was ACK'ed, we know it worked so we'll update the internal state of the device and emit the signals to notify others of the state change. + These commands only affect the state of the relay. They respect the + momentary_secs length. However: + + THESE COMMANDS DO NOT RESPECT THE A,B,C NATURE OF THE MOMENTARY MODE + + An on command will always turn the relay on, and an off command + will always turn the relay off, regardless of the sensor state or how + the device was linked or the contents of the responder entry Data1 + Args: msg (message.InpStandard): The reply message from the device. The on/off level will be in the cmd2 field. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - # Note: don't update the state - the sensor does that. This state is - # for the relay. + # This state is for the relay. if msg.flags.type == Msg.Flags.Type.DIRECT_ACK: LOG.debug("IOLinc %s ACK: %s", self.addr, msg) on_done(True, "IOLinc command complete", None) - elif msg.flags.type == Msg.Flags.Type.DIRECT_NAK: - LOG.error("IOLinc %s NAK error: %s, Message: %s", self.addr, - msg.nak_str(), msg) - on_done(False, "IOLinc command failed. " + msg.nak_str(), None) - - #----------------------------------------------------------------------- - def handle_scene(self, msg, on_done): - """Callback for scene simulation commanded messages. + # On command. 0x11: on + if msg.cmd1 == 0x11: + LOG.info("IOLinc %s relay ON", self.addr) + self._set_relay_is_on(True) - This callback is run when we get a reply back from triggering a scene - on the device. If the command was ACK'ed, we know it worked. The - device will then send out standard broadcast messages which will - trigger other updates for the scene devices. - - Args: - msg (message.InpStandard): The reply message from the device. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - # Call the callback. We don't change state here - the device will - # send a regular broadcast message which will run handle_broadcast - # which will then update the state. - if msg.flags.type == Msg.Flags.Type.DIRECT_ACK: - LOG.debug("IOLinc %s ACK: %s", self.addr, msg) - on_done(True, "Scene triggered", None) + # Off command. 0x13: off + elif msg.cmd1 == 0x13: + LOG.info("IOLinc %s relay OFF", self.addr) + self._set_relay_is_on(False) elif msg.flags.type == Msg.Flags.Type.DIRECT_NAK: LOG.error("IOLinc %s NAK error: %s, Message: %s", self.addr, msg.nak_str(), msg) - on_done(False, "Scene trigger failed failed. " + msg.nak_str(), - None) + on_done(False, "IOLinc command failed. " + msg.nak_str(), None) #----------------------------------------------------------------------- def handle_group_cmd(self, addr, msg): @@ -690,25 +881,146 @@ def handle_group_cmd(self, addr, msg): msg.group, addr) return - # Nothing to do - there is no "state" to update since the state we - # care about is the sensor state and this command tells us that the - # relay state was tripped. - LOG.debug("IOLinc %s cmd %#04x", self.addr, msg.cmd1) + # This reflects a change in the relay state. + # Handle on/off commands codes. + if on_off.Mode.is_valid(msg.cmd1): + is_on, mode = on_off.Mode.decode(msg.cmd1) + if self.mode == IOLinc.Modes.MOMENTARY_A: + # In Momentary A the relay only turns on if the cmd matches + # the responder link D1, else it always turns off. Even if + # the momentary time has not elapsed. + if is_on == bool(entry.data[0]): + is_on = True + else: + is_on = False + elif self.mode == IOLinc.Modes.MOMENTARY_B: + # In Momentary B, either On or Off will turn on the Relay + is_on = True + elif self.mode == IOLinc.Modes.MOMENTARY_C: + # In Momentary C the relay turns on if the cmd is ON and + # sensor state matches the responder link D1, + # OR + # if the cmd is OFF and the sensor state does not match + # the responder link D1 + # All other combinations are turn off + if is_on and bool(entry.data[0]) == self._sensor_is_on: + is_on = True + elif not is_on and bool(entry.data[0]) != self._sensor_is_on: + is_on = True + else: + is_on = False + self._set_relay_is_on(is_on, on_off.REASON_SCENE) + else: + LOG.warning("IOLinc %s unknown group cmd %#04x", self.addr, + msg.cmd1) #----------------------------------------------------------------------- - def _set_is_on(self, is_on): - """Update the device on/off state. + def _set_sensor_is_on(self, is_on, reason=""): + """Update the device sensor on/off state. - This will change the internal state and emit the state changed - signals. It is called by whenever we're informed that the device has - changed state. + This will change the internal state of the sensor and emit the state + changed signals. It is called by whenever we're informed that the + device has changed state. + + Args: + is_on (bool): True if the sensor is on, False if it isn't. + """ + LOG.info("Setting device %s sensor on %s", self.label, is_on) + self._sensor_is_on = bool(is_on) + + self.signal_on_off.emit(self, self._sensor_is_on, self._relay_is_on) + + #----------------------------------------------------------------------- + def _set_relay_is_on(self, is_on, reason="", momentary=False): + """Update the device relay on/off state. + + This will change the internal state of the relay and emit the state + changed signals. It is called by whenever we're informed that the + device has changed state. Args: is_on (bool): True if the relay is on, False if it isn't. + reason (string): The reason for the state + momemtary (bool): Used to write message to log if this was called in + response to a timed call + """ + if momentary: + LOG.info("IOLinc %s automatic update relay on %s", + self.label, is_on) + else: + LOG.info("IOLinc %s relay on %s", self.label, is_on) + self._relay_is_on = bool(is_on) + + self.signal_on_off.emit(self, self._sensor_is_on, self._relay_is_on) + + if is_on and self.mode is not IOLinc.Modes.LATCHING: + # First remove any pending call, we want to reset the clock + if self._momentary_call is not None: + self.modem.timed_call.remove(self._momentary_call) + # Set timer to turn relay off after momentary time + run_time = time.time() + self.momentary_secs + LOG.info("IOLinc %s delayed relay update in %s seconds", + self.label, self.momentary_secs) + self._momentary_call = \ + self.modem.timed_call.add(run_time, self._set_relay_is_on, + False, reason=reason, momentary=True) + elif not is_on and self._momentary_call: + if self.modem.timed_call.remove(self._momentary_call): + LOG.info("IOLinc %s relay off, removing delayed update", + self.label) + self._momentary_call = None + + #----------------------------------------------------------------------- + def link_data_to_pretty(self, is_controller, data): + """Converts Link Data1-3 to Human Readable Attributes + + This takes a list of the data values 1-3 and returns a dict with + the human readable attibutes as keys and the human readable values + as values. + + Args: + is_controller (bool): True if the device is the controller, false + if it's the responder. + data (list[3]): List of three data values. + + Returns: + list[3]: list, containing a dict of the human readable values """ - LOG.info("Setting device %s on %s", self.label, is_on) - self._is_on = bool(is_on) + ret = [{'data_1': data[0]}, {'data_2': data[1]}, {'data_3': data[2]}] + if not is_controller: + on = 1 if data[0] else 0 + ret = [{'on_off': on}, + {'data_2': data[1]}, + {'data_3': data[2]}] + return ret + + #----------------------------------------------------------------------- + def link_data_from_pretty(self, is_controller, data): + """Converts Link Data1-3 from Human Readable Attributes + + This takes a dict of the human readable attributes as keys and their + associated values and returns a list of the data1-3 values. - self.signal_on_off.emit(self, self._is_on) + Args: + is_controller (bool): True if the device is the controller, false + if it's the responder. + data (dict[3]): Dict of three data values. + + Returns: + list[3]: List of Data1-3 values + """ + data_1 = None + if 'data_1' in data: + data_1 = data['data_1'] + data_2 = None + if 'data_2' in data: + data_2 = data['data_2'] + data_3 = None + if 'data_3' in data: + data_3 = data['data_3'] + if not is_controller: + if 'on_off' in data: + data_1 = 0xFF if data['on_off'] else 0x00 + return [data_1, data_2, data_3] #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/KeypadLinc.py b/insteon_mqtt/device/KeypadLinc.py index 32e66479..c57b88fd 100644 --- a/insteon_mqtt/device/KeypadLinc.py +++ b/insteon_mqtt/device/KeypadLinc.py @@ -147,7 +147,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "KeypadLinc paired", on_done) + seq = CommandSeq(self, "KeypadLinc paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -206,7 +206,7 @@ def refresh(self, force=False, on_done=None): # Send a 0x19 0x01 command to get the LED light on/off flags. LOG.info("KeypadLinc %s cmd: keypad status refresh", self.addr) - seq = CommandSeq(self.protocol, "Refresh complete", on_done) + seq = CommandSeq(self, "Refresh complete", on_done) # TODO: change this to 0x2e get extended which reads on mask, off # mask, on level, led brightness, non-toggle mask, led bit mask (led @@ -790,7 +790,7 @@ def set_backlight(self, level, on_done=None): # Otherwise use the level changing command. else: - seq = CommandSeq(self.protocol, "Backlight level", on_done) + seq = CommandSeq(self, "Backlight level", on_done) # Bound to 0x11 <= level <= 0x7f per page 157 of insteon dev guide. level = max(0x11, min(level, 0x7f)) @@ -909,7 +909,7 @@ def set_flags(self, on_done, **kwargs): "flags are: %s" % (unknown, flags)) # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self.protocol, "KeypadLinc set_flags complete", + seq = CommandSeq(self, "KeypadLinc set_flags complete", on_done) # Get the group if it was set. diff --git a/insteon_mqtt/device/Leak.py b/insteon_mqtt/device/Leak.py index 17cc2f78..9a3f3e46 100644 --- a/insteon_mqtt/device/Leak.py +++ b/insteon_mqtt/device/Leak.py @@ -3,7 +3,7 @@ # Insteon leak sensor # #=========================================================================== -from .Base import Base +from .BatterySensor import BatterySensor from ..CommandSeq import CommandSeq from .. import log from ..Signal import Signal @@ -11,7 +11,7 @@ LOG = log.get_logger() -class Leak(Base): +class Leak(BatterySensor): """Insteon battery powered water leak sensor. A leak sensor is basically an on/off sensor except that it's batter @@ -42,6 +42,8 @@ class Leak(Base): - signal_heartbeat( Device, True ): Sent when the device has broadcast a heartbeat signal. """ + type_name = "leak_sensor" + def __init__(self, protocol, modem, address, name=None): """Constructor @@ -57,8 +59,6 @@ def __init__(self, protocol, modem, address, name=None): # Wet/dry signal. API: func( Device, bool is_wet ) self.signal_wet = Signal() - # Sensor heartbeat signal. API: func( Device, True ) - self.signal_heartbeat = Signal() # (Device, bool) # Maps Insteon groups to message type for this sensor. self.group_map = { @@ -93,7 +93,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "LeakSensor paired", on_done) + seq = CommandSeq(self, "LeakSensor paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -120,56 +120,6 @@ def pair(self, on_done=None): # will chain everything together. seq.run() - #----------------------------------------------------------------------- - def handle_broadcast(self, msg): - """Handle broadcast messages from this device. - - A broadcast message is sent from the device when any activity is - triggered. - - This callback will process the broadcast and emit the signals that - correspond the events. - - Then the base class handle_broadcast() is called. That will loop - over every device that is linked to this device in the database and - call handle_group_cmd() on those devices. That insures that the - devices that are linked to this device get updated to their correct - states (Insteon devices don't send out a state change when the - respond to a broadcast). - - Args: - msg (InpStandard): Broadcast message from the device. - """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == 0x06: - LOG.info("LeakSensor %s broadcast ACK grp: %s", self.addr, - msg.group) - - # On (0x11) and off (0x13) commands. - elif msg.cmd1 == 0x11 or msg.cmd1 == 0x13: - LOG.info("LeakSensor %s broadcast cmd %s grp: %s", self.addr, - msg.cmd1, msg.group) - - # Find the callback for this group and run that. - handler = self.group_map.get(msg.group, None) - if handler: - handler(msg) - else: - LOG.error("LeakSensor no handler for group %s", msg.group) - - # This will find all the devices we're the controller of for this - # group and call their handle_group_cmd() methods to update their - # states since they will have seen the group broadcast and updated - # (without sending anything out). - super().handle_broadcast(msg) - - # If we haven't downloaded the device db yet, use this opportunity to - # get the device db since we know the sensor is awake. This doesn't - # always seem to work, but it works often enough to be useful to try. - if len(self.db) == 0: - LOG.info("LeakSensor %s awake - requesting database", self.addr) - self.refresh(force=True) - #----------------------------------------------------------------------- def handle_dry(self, msg): """Handle a dry message. diff --git a/insteon_mqtt/device/Motion.py b/insteon_mqtt/device/Motion.py index beeb1fc8..b9fa76b3 100644 --- a/insteon_mqtt/device/Motion.py +++ b/insteon_mqtt/device/Motion.py @@ -55,6 +55,8 @@ class Motion(BatterySensor): the light level (dusk/dawn) has changed. Not all motion sensors support this. """ + type_name = "motion_sensor" + def __init__(self, protocol, modem, address, name=None): """Constructor @@ -141,7 +143,7 @@ def set_flags(self, on_done, **kwargs): raise Exception("Unknown Motion flags input: %s.\n Valid flags " "are: %s" % (unknown, flags)) - seq = CommandSeq(self.protocol, "Motion Set Flags Success", on_done) + seq = CommandSeq(self, "Motion Set Flags Success", on_done) # For some flags we need to know the existing bit before we change it. # So to insure that we are starting from the correct values, get the diff --git a/insteon_mqtt/device/Outlet.py b/insteon_mqtt/device/Outlet.py index 491ebfa5..2e136f2e 100644 --- a/insteon_mqtt/device/Outlet.py +++ b/insteon_mqtt/device/Outlet.py @@ -97,7 +97,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "Outlet paired", on_done) + seq = CommandSeq(self, "Outlet paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -145,7 +145,7 @@ def refresh(self, force=False, on_done=None): """ LOG.info("Outlet %s cmd: status refresh", self.label) - seq = CommandSeq(self.protocol, "Device refreshed", on_done) + seq = CommandSeq(self, "Device refreshed", on_done) # This sends a refresh ping which will respond w/ the current # database delta field. The handler checks that against the current @@ -414,7 +414,7 @@ def set_flags(self, on_done, **kwargs): "are: %s" % unknown, flags) # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self.protocol, "Outlet set_flags complete", on_done) + seq = CommandSeq(self, "Outlet set_flags complete", on_done) if FLAG_BACKLIGHT in kwargs: backlight = util.input_byte(kwargs, FLAG_BACKLIGHT) diff --git a/insteon_mqtt/device/Remote.py b/insteon_mqtt/device/Remote.py index a511fe3b..f5ea1d03 100644 --- a/insteon_mqtt/device/Remote.py +++ b/insteon_mqtt/device/Remote.py @@ -3,17 +3,17 @@ # Remote module # #=========================================================================== +from .BatterySensor import BatterySensor from ..CommandSeq import CommandSeq from .. import log from .. import on_off from ..Signal import Signal -from .Base import Base from .. import util LOG = log.get_logger() -class Remote(Base): +class Remote(BatterySensor): """Insteon multi-button battery powered mini-remote device. This class can be used for 1, 4, 6 or 8 (really any number) of battery @@ -56,6 +56,12 @@ def __init__(self, protocol, modem, address, name, num_button): self.num = num_button self.type_name = "mini_remote_%d" % self.num + # Even though all buttons use the same callback this creats + # symmetry with the rest of the codebase + self.group_map = {} + for i in range(1, self.num + 1): + self.group_map[i] = self.handle_button + # Button pressed signal. # API: func(Device, int group, bool on, on_off.Mode mode) self.signal_pressed = Signal() @@ -89,7 +95,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "Remote paired", on_done) + seq = CommandSeq(self, "Remote paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -114,30 +120,17 @@ def pair(self, on_done=None): seq.run() #----------------------------------------------------------------------- - def handle_broadcast(self, msg): - """Handle broadcast messages from this device. - - This is called automatically by the system (via handle.Broadcast) - when we receive a message from the device. + def handle_button(self, msg): + """Handle button presses and hold downs - The broadcast message from a device is sent when the device is - triggered. The message has the group ID in it. We'll update the - device state and look up the group in the all link database. For - each device that is in the group (as a reponsder), we'll call - handle_group_cmd() on that device to trigger it. This way all the - devices in the group are updated to the correct values when we see - the broadcast message. + This is called by the device when a group broadcast is + sent out by the sensor. Args: msg (InpStandard): Broadcast message from the device. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == 0x06: - LOG.info("Remote %s broadcast ACK grp: %s", self.addr, msg.group) - return - # On/off command codes. - elif on_off.Mode.is_valid(msg.cmd1): + if on_off.Mode.is_valid(msg.cmd1): is_on, mode = on_off.Mode.decode(msg.cmd1) LOG.info("Remote %s broadcast grp: %s on: %s mode: %s", self.addr, msg.group, is_on, mode) @@ -153,19 +146,6 @@ def handle_broadcast(self, msg): self.signal_manual.emit(self, msg.group, manual) - # This will find all the devices we're the controller of for this - # group and call their handle_group_cmd() methods to update their - # states since they will have seen the group broadcast and updated - # (without sending anything out). - super().handle_broadcast(msg) - - # If we haven't downloaded the device db yet, use this opportunity to - # get the device db since we know the sensor is awake. This doesn't - # always seem to work, but it works often enough to be useful to try. - if len(self.db) == 0: - LOG.info("Remote %s awake - requesting database", self.addr) - self.refresh(force=True) - #----------------------------------------------------------------------- def link_data(self, is_controller, group, data=None): """Create default device 3 byte link data. diff --git a/insteon_mqtt/device/SmokeBridge.py b/insteon_mqtt/device/SmokeBridge.py index c3a77449..5d5834fc 100644 --- a/insteon_mqtt/device/SmokeBridge.py +++ b/insteon_mqtt/device/SmokeBridge.py @@ -87,7 +87,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "SmokeBridge paired", on_done) + seq = CommandSeq(self, "SmokeBridge paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -130,7 +130,7 @@ def refresh(self, force=False, on_done=None): """ LOG.info("Smoke bridge %s cmd: status refresh", self.addr) - seq = CommandSeq(self.protocol, "Device refreshed", on_done) + seq = CommandSeq(self, "Device refreshed", on_done) # There is no way to get the current device status but we can request # the all link database delta so get that. See smoke bridge dev diff --git a/insteon_mqtt/device/Switch.py b/insteon_mqtt/device/Switch.py index 663125f6..20d51fb0 100644 --- a/insteon_mqtt/device/Switch.py +++ b/insteon_mqtt/device/Switch.py @@ -92,7 +92,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "Switch paired", on_done) + seq = CommandSeq(self, "Switch paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -345,7 +345,7 @@ def set_flags(self, on_done, **kwargs): "are: %s" % unknown, flags) # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self.protocol, "Switch set_flags complete", on_done) + seq = CommandSeq(self, "Switch set_flags complete", on_done) if FLAG_BACKLIGHT in kwargs: backlight = util.input_byte(kwargs, FLAG_BACKLIGHT) diff --git a/insteon_mqtt/device/Thermostat.py b/insteon_mqtt/device/Thermostat.py index 250693cd..a31736f8 100644 --- a/insteon_mqtt/device/Thermostat.py +++ b/insteon_mqtt/device/Thermostat.py @@ -167,7 +167,7 @@ def pair(self, on_done=None): # call finishes and works before calling the next one. We have to do # this for device db manipulation because we need to know the memory # layout on the device before making changes. - seq = CommandSeq(self.protocol, "Thermostat paired", on_done) + seq = CommandSeq(self, "Thermostat paired", on_done) # Start with a refresh command - since we're changing the db, it must # be up to date or bad things will happen. @@ -223,7 +223,7 @@ def refresh(self, force=False, on_done=None): """ LOG.info("Device %s cmd: fan status refresh", self.addr) - seq = CommandSeq(self.protocol, "Refresh complete", on_done) + seq = CommandSeq(self, "Refresh complete", on_done) # Send a 0x19 0x03 command to get the fan speed level. This sends a # refresh ping which will respond w/ the fan level and current diff --git a/insteon_mqtt/handler/Base.py b/insteon_mqtt/handler/Base.py index 24831b32..13e0b32a 100644 --- a/insteon_mqtt/handler/Base.py +++ b/insteon_mqtt/handler/Base.py @@ -123,6 +123,8 @@ def is_expired(self, protocol, t): # Otherwise we should try and resend the message with ourselves as # the handler again so we don't lose the count. + # This calls protocol rather then device so that the hops count is + # correcly set, also since we don't have the Device object here protocol.send(self._msg, self) # Tell the protocol that we're expired. This will end this handler diff --git a/insteon_mqtt/handler/DeviceDbGet.py b/insteon_mqtt/handler/DeviceDbGet.py index 77c9f112..062e91ce 100644 --- a/insteon_mqtt/handler/DeviceDbGet.py +++ b/insteon_mqtt/handler/DeviceDbGet.py @@ -22,7 +22,7 @@ class DeviceDbGet(Base): Each reply is passed to the callback function set in the constructor which is usually a method on the device to update it's database. """ - def __init__(self, device_db, on_done, num_retry=0): + def __init__(self, device_db, on_done, num_retry=3, time_out=5): """Constructor The on_done callback has the signature on_done(success, msg, entry) @@ -38,8 +38,21 @@ def __init__(self, device_db, on_done, num_retry=0): handler times out without returning Msg.FINISHED. This count does include the initial sending so a retry of 3 will send once and then retry 2 more times. + Retries only apply to the initial get request and the ack + of that request. The subsequent messages are streamed from + the device without further requests. If the handler times + out after the initial request, there is no way to recover, + besides starting the request over again. + time_out (int): Timeout in seconds. The regular timeout applies to + the initial request. The subsequent messages are + streamed from the device without further action. + Because the communication from this point on is + entirely one-sided coming from the device. There is + nothing we can do from this end if a message fails to + arrive, so we keep the network as quiet as possible + by doubling the timeout. """ - super().__init__(on_done, num_retry) + super().__init__(on_done, num_retry, time_out) self.db = device_db #----------------------------------------------------------------------- @@ -83,6 +96,10 @@ def msg_received(self, protocol, msg): if msg.flags.type == Msg.Flags.Type.DIRECT_ACK: LOG.info("%s device ACK response", msg.from_addr) + # From here on out, the device is the only one talking. So + # remove any remaining retries, and double the timeout. + self._num_retry = 0 + self._time_out = 2 * self._time_out return Msg.CONTINUE elif msg.flags.type == Msg.Flags.Type.DIRECT_NAK: diff --git a/insteon_mqtt/handler/DeviceDbModify.py b/insteon_mqtt/handler/DeviceDbModify.py index 5a27aade..9e2a3691 100644 --- a/insteon_mqtt/handler/DeviceDbModify.py +++ b/insteon_mqtt/handler/DeviceDbModify.py @@ -18,7 +18,7 @@ class DeviceDbModify(Base): modifications to the device's all link database class to reflect what happened on the physical device. """ - def __init__(self, device_db, entry, on_done=None): + def __init__(self, device_db, entry, on_done=None, num_retry=3): """Constructor Args: @@ -29,7 +29,7 @@ def __init__(self, device_db, entry, on_done=None): added to the handler. Signature is: on_done(success, msg, entry) """ - super().__init__(on_done) + super().__init__(on_done, num_retry) self.db = device_db self.entry = entry diff --git a/insteon_mqtt/handler/DeviceRefresh.py b/insteon_mqtt/handler/DeviceRefresh.py index 869f7c76..86464ec4 100644 --- a/insteon_mqtt/handler/DeviceRefresh.py +++ b/insteon_mqtt/handler/DeviceRefresh.py @@ -78,60 +78,65 @@ def msg_received(self, protocol, msg): # See if this is the standard message ack/nak we're expecting. elif isinstance(msg, Msg.InpStandard) and msg.from_addr == self.addr: - # Since we got the message we expected, turn off retries. - self.stop_retry() - - # All link database delta is stored in cmd1 so we if we have the - # latest version. If not, schedule an update. - need_refresh = True - if self.skip_db: - need_refresh = False - elif not self.force and self.device.db.is_current(msg.cmd1): - LOG.ui("Device database is current at delta %s", msg.cmd1) - need_refresh = False - - # Call the device refresh handler. This sets the current device - # state which is usually stored in cmd2. - self.callback(msg) - - if not need_refresh: - self.on_done(True, "Refresh complete", None) - else: - LOG.ui("Device %s db out of date (got %s vs %s), refreshing", - self.addr, msg.cmd1, self.device.db.delta) - - # Clear the current database values. - self.device.db.clear() - - # When the update message below ends, update the db delta w/ - # the current value and save the database. - def on_done(success, message, data): - if success: - self.device.db.set_delta(msg.cmd1) - LOG.ui("%s database download complete\n%s", - self.addr, self.device.db) - self.on_done(success, message, data) - - # Request that the device send us all of it's database - # records. These will be streamed as fast as possible to us - # and the handler will update the database. We need a retry - # count here because battery powered devices don't always - # respond right away. - if self.device.db.engine == 0: - scan_manager = db.DeviceScanManagerI1(self.device, - self.device.db, - on_done=on_done, - num_retry=3) - scan_manager.start_scan() + if msg.flags.type == Msg.Flags.Type.DIRECT_ACK: + # All link database delta is stored in cmd1 so we if we have + # the latest version. If not, schedule an update. + need_refresh = True + if self.skip_db: + need_refresh = False + elif not self.force and self.device.db.is_current(msg.cmd1): + LOG.ui("Device database is current at delta %s", msg.cmd1) + need_refresh = False + + # Call the device refresh handler. This sets the current + # device state which is usually stored in cmd2. + self.callback(msg) + + if not need_refresh: + self.on_done(True, "Refresh complete", None) else: - db_msg = Msg.OutExtended.direct(self.addr, 0x2f, 0x00, - bytes(14)) - msg_handler = DeviceDbGet(self.device.db, on_done, - num_retry=3) - self.device.send(db_msg, msg_handler) - - # Either way - this transaction is complete. - return Msg.FINISHED + LOG.ui("Device %s db out of date (got %s vs %s), " + + "refreshing", self.addr, msg.cmd1, + self.device.db.delta) + + # Clear the current database values. + self.device.db.clear() + + # When the update message below ends, update the db delta + # w/ the current value and save the database. + def on_done(success, message, data): + if success: + self.device.db.set_delta(msg.cmd1) + LOG.ui("%s database download complete\n%s", + self.addr, self.device.db) + self.on_done(success, message, data) + + # Request that the device send us all of it's database + # records. These will be streamed as fast as possible to + # us and the handler will update the database. We need a + # retry count here because battery powered devices don't + # always respond right away. + if self.device.db.engine == 0: + scan_manager = db.DeviceScanManagerI1(self.device, + self.device.db, + on_done=on_done, + num_retry=3) + scan_manager.start_scan() + else: + db_msg = Msg.OutExtended.direct(self.addr, 0x2f, 0x00, + bytes(14)) + msg_handler = DeviceDbGet(self.device.db, on_done, + num_retry=3) + self.device.send(db_msg, msg_handler) + # Either way - this transaction is complete. + return Msg.FINISHED + + elif msg.flags.type == Msg.Flags.Type.DIRECT_NAK: + LOG.error("Device %s refresh NAK: %s, Message: %s", + self.device.label, msg.nak_str(), msg) + self.on_done(False, "Device refresh failed. " + + msg.nak_str(), None) + return Msg.FINISHED # Unknown message - not for us. return Msg.UNKNOWN diff --git a/insteon_mqtt/handler/ModemDbGet.py b/insteon_mqtt/handler/ModemDbGet.py index 6779d374..213c9e48 100644 --- a/insteon_mqtt/handler/ModemDbGet.py +++ b/insteon_mqtt/handler/ModemDbGet.py @@ -88,7 +88,7 @@ def msg_received(self, protocol, msg): # Request the next record in the PLM database. LOG.info("Modem requesting next db record") msg = Msg.OutAllLinkGetNext() - protocol.send(msg, self) + self.db.device.send(msg, self) # Return finished - this way the getnext message will go out. # We'll be used as the handler for that as well which repeats diff --git a/insteon_mqtt/handler/ModemDbModify.py b/insteon_mqtt/handler/ModemDbModify.py index f7b54586..229a6752 100644 --- a/insteon_mqtt/handler/ModemDbModify.py +++ b/insteon_mqtt/handler/ModemDbModify.py @@ -121,7 +121,7 @@ def msg_received(self, protocol, msg): if self.next: LOG.info("Sending next modem db update") msg, self.entry = self.next.pop(0) - protocol.send(msg, self) + self.db.device.send(msg, self) # Only run the callback if this is the last message in the chain. else: diff --git a/insteon_mqtt/mqtt/BatterySensor.py b/insteon_mqtt/mqtt/BatterySensor.py index 9db462cf..d7bfdf8e 100644 --- a/insteon_mqtt/mqtt/BatterySensor.py +++ b/insteon_mqtt/mqtt/BatterySensor.py @@ -3,6 +3,7 @@ # MQTT battery sensor device # #=========================================================================== +import time from .. import log from .MsgTemplate import MsgTemplate @@ -36,11 +37,15 @@ def __init__(self, mqtt, device): self.msg_battery = MsgTemplate( topic='insteon/{{address}}/low_battery', payload='{{is_low_str.lower()}}') + self.msg_heartbeat = MsgTemplate( + topic='insteon/{{address}}/heartbeat', + payload='{{heartbeat_time}}') # Connect the signals from the insteon device so we get notified of # changes. device.signal_on_off.connect(self._insteon_on_off) device.signal_low_battery.connect(self._insteon_low_battery) + device.signal_heartbeat.connect(self._insteon_heartbeat) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -58,6 +63,8 @@ def load_config(self, config, qos=None): self.msg_state.load_config(data, 'state_topic', 'state_payload', qos) self.msg_battery.load_config(data, 'low_battery_topic', 'low_battery_payload', qos) + self.msg_heartbeat.load_config(data, 'heartbeat_topic', + 'heartbeat_payload', qos) #----------------------------------------------------------------------- def subscribe(self, link, qos): @@ -84,7 +91,7 @@ def unsubscribe(self, link): pass #----------------------------------------------------------------------- - def template_data(self, is_on=None, is_low=None): + def template_data(self, is_on=None, is_low=None, is_heartbeat=None): """Create the Jinja templating data variables. Args: @@ -111,6 +118,11 @@ def template_data(self, is_on=None, is_low=None): data["is_low"] = 1 if is_low else 0 data["is_low_str"] = "on" if is_low else "off" + if is_heartbeat is not None: + data["is_heartbeat"] = 1 if is_heartbeat else 0 + data["is_heartbeat_str"] = "on" if is_heartbeat else "off" + data["heartbeat_time"] = time.time() if is_heartbeat else 0 + return data #----------------------------------------------------------------------- @@ -146,3 +158,20 @@ def _insteon_low_battery(self, device, is_low): self.msg_battery.publish(self.mqtt, data) #----------------------------------------------------------------------- + def _insteon_heartbeat(self, device, is_heartbeat): + """Device heartbeat on/off callback. + + This is triggered via signal when the Insteon device receive a + heartbeat. It will publish an MQTT message with the new date. + + Args: + device (device.Leak): The Insteon device that changed. + is_heartbeat (bool): True for heartbeat, False for not. + """ + LOG.info("MQTT received heartbeat %s = %s", device.label, + is_heartbeat) + + data = self.template_data(is_heartbeat=is_heartbeat) + self.msg_heartbeat.publish(self.mqtt, data) + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/IOLinc.py b/insteon_mqtt/mqtt/IOLinc.py index 5f373351..c3e70171 100644 --- a/insteon_mqtt/mqtt/IOLinc.py +++ b/insteon_mqtt/mqtt/IOLinc.py @@ -30,18 +30,24 @@ def __init__(self, mqtt, device): # Output state change reporting template. self.msg_state = MsgTemplate( topic='insteon/{{address}}/state', - payload='{{on_str.lower()}}') + payload='{"sensor": "{{sensor_on_str.lower()}}",' + + ' "relay": "{{relay_on_str.lower()}}"}') + + # Output relay state change reporting template. + self.msg_relay_state = MsgTemplate( + topic='insteon/{{address}}/relay', + payload='{{relay_on_str.lower()}}') + + # Output sensor state change reporting template. + self.msg_sensor_state = MsgTemplate( + topic='insteon/{{address}}/sensor', + payload='{{sensor_on_str.lower()}}') # Input on/off command template. self.msg_on_off = MsgTemplate( topic='insteon/{{address}}/set', payload='{ "cmd" : "{{value.lower()}}" }') - # Input scene on/off command template. - self.msg_scene = MsgTemplate( - topic='insteon/{{address}}/scene', - payload='{ "cmd" : "{{value.lower()}}" }') - device.signal_on_off.connect(self._insteon_on_off) #----------------------------------------------------------------------- @@ -58,9 +64,12 @@ def load_config(self, config, qos=None): return self.msg_state.load_config(data, 'state_topic', 'state_payload', qos) + self.msg_relay_state.load_config(data, 'relay_state_topic', + 'relay_state_payload', qos) + self.msg_sensor_state.load_config(data, 'sensor_state_topic', + 'sensor_state_payload', qos) self.msg_on_off.load_config(data, 'on_off_topic', 'on_off_payload', qos) - self.msg_scene.load_config(data, 'scene_topic', 'scene_payload', qos) #----------------------------------------------------------------------- def subscribe(self, link, qos): @@ -77,10 +86,6 @@ def subscribe(self, link, qos): topic = self.msg_on_off.render_topic(self.template_data()) link.subscribe(topic, qos, self._input_on_off) - # Scene triggering messages. - topic = self.msg_scene.render_topic(self.template_data()) - link.subscribe(topic, qos, self._input_scene) - #----------------------------------------------------------------------- def unsubscribe(self, link): """Unsubscribe to any MQTT topics the object was subscribed to. @@ -91,11 +96,8 @@ def unsubscribe(self, link): topic = self.msg_on_off.render_topic(self.template_data()) link.unsubscribe(topic) - topic = self.msg_scene.render_topic(self.template_data()) - link.unsubscribe(topic) - #----------------------------------------------------------------------- - def template_data(self, is_on=None): + def template_data(self, sensor_is_on=None, relay_is_on=None): """Create the Jinja templating data variables for on/off messages. Args: @@ -112,14 +114,17 @@ def template_data(self, is_on=None): else self.device.addr.hex, } - if is_on is not None: - data["on"] = 1 if is_on else 0 - data["on_str"] = "on" if is_on else "off" + if sensor_is_on is not None: + data["sensor_on"] = 1 if sensor_is_on else 0 + data["sensor_on_str"] = "on" if sensor_is_on else "off" + if relay_is_on is not None: + data["relay_on"] = 1 if relay_is_on else 0 + data["relay_on_str"] = "on" if relay_is_on else "off" return data #----------------------------------------------------------------------- - def _insteon_on_off(self, device, is_on): + def _insteon_on_off(self, device, sensor_is_on, relay_is_on): """Device active on/off callback. This is triggered via signal when the Insteon device goes active or @@ -129,10 +134,13 @@ def _insteon_on_off(self, device, is_on): device (device.IOLinc): The Insteon device that changed. is_on (bool): True for on, False for off. """ - LOG.info("MQTT received active change %s = %s", device.label, is_on) + LOG.info("MQTT received active change %s, sensor = %s relay = %s", + device.label, sensor_is_on, relay_is_on) - data = self.template_data(is_on) + data = self.template_data(sensor_is_on, relay_is_on) self.msg_state.publish(self.mqtt, data) + self.msg_relay_state.publish(self.mqtt, data) + self.msg_sensor_state.publish(self.mqtt, data) #----------------------------------------------------------------------- def _input_on_off(self, client, data, message): @@ -161,29 +169,3 @@ def _input_on_off(self, client, data, message): LOG.exception("Invalid IOLinc on/off command: %s", data) #----------------------------------------------------------------------- - def _input_scene(self, client, data, message): - """Handle an input scene MQTT message. - - This is called when we receive a message on the scene trigger MQTT - topic subscription. Parse the message and pass the command to the - Insteon device. - - Args: - client (paho.Client): The paho mqtt client (self.link). - data: Optional user data (unused). - message: MQTT message - has attrs: topic, payload, qos, retain. - """ - LOG.debug("IOLinc message %s %s", message.topic, message.payload) - - # Parse the input MQTT message. - data = self.msg_scene.to_json(message.payload) - LOG.info("IOLinc input command: %s", data) - - try: - # Scenes don't support modes so don't parse that element. - is_on = util.parse_on_off(data, have_mode=False) - self.device.scene(is_on) - except: - LOG.exception("Invalid IOLinc scene command: %s", data) - - #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/Leak.py b/insteon_mqtt/mqtt/Leak.py index 716fc57d..01e291d4 100644 --- a/insteon_mqtt/mqtt/Leak.py +++ b/insteon_mqtt/mqtt/Leak.py @@ -5,12 +5,13 @@ #=========================================================================== import time from .. import log +from .BatterySensor import BatterySensor from .MsgTemplate import MsgTemplate LOG = log.get_logger() -class Leak: +class Leak(BatterySensor): """Leak battery powered sensor. Leak sensors don't support any input commands - they're sleeping until @@ -24,21 +25,16 @@ def __init__(self, mqtt, device): mqtt (mqtt.Mqtt): The MQTT main interface. device (device.Leak): The Insteon object to link to. """ - self.mqtt = mqtt - self.device = device + super().__init__(mqtt, device) # Default values for the topics. self.msg_wet = MsgTemplate( topic='insteon/{{address}}/wet', payload='{{is_wet_str.lower()}}') - self.msg_heartbeat = MsgTemplate( - topic='insteon/{{address}}/heartbeat', - payload='{{heartbeat_time}}') # Connect the two signals from the insteon device so we get notified # of changes. device.signal_wet.connect(self._insteon_wet) - device.signal_heartbeat.connect(self._insteon_heartbeat) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -49,38 +45,23 @@ def load_config(self, config, qos=None): config is stored in config['leak']. qos (int): The default quality of service level to use. """ + # Load the BatterySensor configuration. + super().load_config(config, qos) + data = config.get("leak", None) if not data: return self.msg_wet.load_config(data, 'wet_dry_topic', 'wet_dry_payload', qos) - self.msg_heartbeat.load_config(data, 'heartbeat_topic', - 'heartbeat_payload', qos) - - #----------------------------------------------------------------------- - def subscribe(self, link, qos): - """Subscribe to any MQTT topics the object needs. - - Subscriptions are used when the object has things that can be - commanded to change. - - Args: - link (network.Mqtt): The MQTT network client to use. - qos (int): The quality of service to use. - """ - pass - #----------------------------------------------------------------------- - def unsubscribe(self, link): - """Unsubscribe to any MQTT topics the object was subscribed to. - - Args: - link (network.Mqtt): The MQTT network client to use. - """ - pass + # In versions <= 0.7.2, this was in leak sensor so try and + # load them to insure old config files still work. + if "heartbeat_topic" in data: + self.msg_heartbeat.load_config(data, 'heartbeat_topic', + 'heartbeat_payload', qos) #----------------------------------------------------------------------- - def template_data(self, is_wet=None, is_heartbeat=None): + def template_data_leak(self, is_wet=None): """Create the Jinja templating data variables. Args: @@ -106,11 +87,6 @@ def template_data(self, is_wet=None, is_heartbeat=None): data["is_dry_str"] = "off" if is_wet else "on" data["state"] = "wet" if is_wet else "dry" - if is_heartbeat is not None: - data["is_heartbeat"] = 1 if is_heartbeat else 0 - data["is_heartbeat_str"] = "on" if is_heartbeat else "off" - data["heartbeat_time"] = time.time() if is_heartbeat else 0 - return data #----------------------------------------------------------------------- @@ -128,22 +104,5 @@ def _insteon_wet(self, device, is_wet): LOG.info("MQTT received wet/dry change %s wet= %s", device.label, is_wet) - data = self.template_data(is_wet) + data = self.template_data_leak(is_wet) self.msg_wet.publish(self.mqtt, data) - - #----------------------------------------------------------------------- - def _insteon_heartbeat(self, device, is_heartbeat): - """Device heartbeat on/off callback. - - This is triggered via signal when the Insteon device receive a - heartbeat. It will publish an MQTT message with the new date. - - Args: - device (device.Leak): The Insteon device that changed. - is_heartbeat (bool): True for heartbeat, False for not. - """ - LOG.info("MQTT received heartbeat %s = %s", device.label, - is_heartbeat) - - data = self.template_data(is_heartbeat=is_heartbeat) - self.msg_heartbeat.publish(self.mqtt, data) diff --git a/insteon_mqtt/mqtt/Remote.py b/insteon_mqtt/mqtt/Remote.py index 1e5e7076..46517eed 100644 --- a/insteon_mqtt/mqtt/Remote.py +++ b/insteon_mqtt/mqtt/Remote.py @@ -5,12 +5,13 @@ #=========================================================================== from .. import log from .. import on_off +from .BatterySensor import BatterySensor from .MsgTemplate import MsgTemplate LOG = log.get_logger() -class Remote: +class Remote(BatterySensor): """MQTT interface to an Insteon mini-remote. This class connects to a device.Remote object and converts it's output @@ -25,8 +26,7 @@ def __init__(self, mqtt, device): mqtt (mqtt.Mqtt): The MQTT main interface. device (device.Remote): The Insteon object to link to. """ - self.mqtt = mqtt - self.device = device + super().__init__(mqtt, device) self.msg_state = MsgTemplate( topic='insteon/{{address}}/state/{{button}}', @@ -47,6 +47,8 @@ def load_config(self, config, qos=None): config is stored in config['remote']. qos (int): The default quality of service level to use. """ + super().load_config(config, qos) + data = config.get("remote", None) if not data: return @@ -55,33 +57,15 @@ def load_config(self, config, qos=None): self.msg_manual_state.load_config(data, 'manual_state_topic', 'manual_state_payload', qos) - #----------------------------------------------------------------------- - def subscribe(self, link, qos): - """Subscribe to any MQTT topics the object needs. - - Subscriptions are used when the object has things that can be - commanded to change. - - Args: - link (network.Mqtt): The MQTT network client to use. - qos (int): The quality of service to use. - """ - # There are no input controls for this object so we don't need to - # subscribe to anything. - pass - - #----------------------------------------------------------------------- - def unsubscribe(self, link): - """Unsubscribe to any MQTT topics the object was subscribed to. - - Args: - link (network.Mqtt): The MQTT network client to use. - """ - pass + # Leak and Motion allow for overrides b/c of grandfathering. But I + # think is may be a helpful feature, so enabling here too. + if "low_battery_topic" in data: + self.msg_battery.load_config(data, 'low_battery_topic', + 'low_battery_payload', qos) #----------------------------------------------------------------------- - def template_data(self, button, is_on=None, mode=on_off.Mode.NORMAL, - manual=None): + def template_data_remote(self, button, is_on=None, mode=on_off.Mode.NORMAL, + manual=None): """Create the Jinja templating data variables for on/off messages. Args: @@ -144,7 +128,7 @@ def _insteon_pressed(self, device, button, is_on, mode=on_off.Mode.NORMAL): # the broker. retain = False - data = self.template_data(button, is_on, mode) + data = self.template_data_remote(button, is_on, mode) self.msg_state.publish(self.mqtt, data, retain=retain) #----------------------------------------------------------------------- @@ -161,7 +145,7 @@ def _insteon_manual(self, device, group, manual): LOG.info("MQTT received manual button press %s = btn %s %s", device.label, group, manual) - data = self.template_data(group, manual=manual) + data = self.template_data_remote(group, manual=manual) self.msg_manual_state.publish(self.mqtt, data, retain=False) #----------------------------------------------------------------------- diff --git a/insteon_mqtt/network/Mqtt.py b/insteon_mqtt/network/Mqtt.py index 67dc982d..6289a239 100644 --- a/insteon_mqtt/network/Mqtt.py +++ b/insteon_mqtt/network/Mqtt.py @@ -179,8 +179,10 @@ def poll(self, t): passed in so that all clients receive the same "current" time instead of each calling time.time() and getting a different value. """ - # This is required to handle keepalive messages. - self.client.loop_misc() + # This is required to handle keepalive messages and detect disconnections. + rc = self.client.loop_misc() + if rc == paho.MQTT_ERR_NO_CONN: + self._on_disconnect(self.client, None, rc) #----------------------------------------------------------------------- def retry_connect_dt(self): diff --git a/insteon_mqtt/network/TimedCall.py b/insteon_mqtt/network/TimedCall.py new file mode 100644 index 00000000..575814b7 --- /dev/null +++ b/insteon_mqtt/network/TimedCall.py @@ -0,0 +1,134 @@ +#=========================================================================== +# +# TimedCall class definition. +# +#=========================================================================== +from ..Signal import Signal +from .. import log + +LOG = log.get_logger(__name__) + + +class TimedCall: + """A Fake Network Interface for Queueing and 'Asynchronously' Running + Functional Calls at Specific Times + + This is a polling only network "link". Unlike regular links that do read + and write operations when they report they are ready, this class is + designed to only be polled during the event loop. + + This is like a network link for reading and writing but that is handled + by the network manager. But in reality it is just a wrapper for inserting + function calls into the network loop near specific time. This allows + function calls to be scheduled to run at specific times. + + This isn't true asynchronous functionality, there is no gaurantee that the + call will run at the time specified, only that it will run at some point + after the specified time. In general, this lag is minimal, likely tens of + milliseconds. However, as a result, this class should not be used for + time critical functions. + + This class was originally created to handle the reverting of the relay + state for momentary switching on the IOLinc. Other time based objects + may also benefit from this. + """ + + def __init__(self): + """Constructor. Mostly just defines some attributes that are expected + but un-needed. + """ + # Sent when the link is going down. signature: (Link link) + self.signal_closing = Signal() + + # The manager will emit this after the connection has been + # established and everything is ready. Links should usually not emit + # this directly. signature: (Link link, bool connected) + self.signal_connected = Signal() + + # The list of functions to call. Each item should be a + # CallObject + self.calls = [] + + #----------------------------------------------------------------------- + def poll(self, t): + """Periodic poll callback. + + The manager will call this at recurring intervals in case the link + needs to do some periodic manual processing. + + This is where we inject the function calls. The main loop calls this + once per loop. This checks to see if the time associated with any of + the CallObjects has elapsed. If it has, call the function. + + Only a single call is performed each loop. Currently, there is no + reason to think that multiple calls would be necessary. + + Args: + t (float): Current Unix clock time tag. + """ + if len(self.calls) > 0: + if self.calls[0].time < t: + entry = self.calls.pop(0) + try: + entry.func(*entry.args, **entry.kwargs) + except: + LOG.error("Error in executing TimedCall function") + + #----------------------------------------------------------------------- + def add(self, time, func, *args, **kwargs): + """Adds a call to the calls list and sorts the list + + Args: + time (float): The Unix clock time tag at which the call should run + func (function): The function to run + ars & kwargs: Passed to the function when run + Returns: + The created (CallObject) + """ + new_call = CallObject(time, func, *args, **kwargs) + self.calls.append(new_call) + self.calls.sort(key=lambda call: call.time) + return new_call + + #----------------------------------------------------------------------- + def remove(self, call): + """Removes a call from the calls list + + Args: + call (CallObject): The CallObject to delete, from add() + Returns: + True if a call was removed, False otherwise + """ + ret = False + if call in self.calls: + self.calls.remove(call) + ret = True + return ret + + #----------------------------------------------------------------------- + def close(self): + """Close the link. + + The link must call self.signal_closing.emit() after closing. + """ + self.signal_closing.emit() + + #----------------------------------------------------------------------- + + +#=========================================================================== +class CallObject: + """A Simple Class for Associating a Time with a Call + """ + + def __init__(self, time, func, *args, **kwargs): + """Constructor + + Args: + error_stop (bool): If True, will skip the remaining funciton calls + if any function call raises an exception. + """ + self.time = time + self.func = func + self.args = args + self.kwargs = kwargs diff --git a/insteon_mqtt/network/__init__.py b/insteon_mqtt/network/__init__.py index 6d5930cb..0f10840d 100644 --- a/insteon_mqtt/network/__init__.py +++ b/insteon_mqtt/network/__init__.py @@ -22,6 +22,7 @@ from .Serial import Serial from .Stack import Stack from .Mqtt import Mqtt +from .TimedCall import TimedCall # Use Poll on non-windows systems - For windows we have to use select. import platform # pylint: disable=wrong-import-order diff --git a/scenes.yaml b/scenes.yaml index 52f0f192..f6e0de6c 100644 --- a/scenes.yaml +++ b/scenes.yaml @@ -118,3 +118,42 @@ - master fan controllers: - master remote + +# IOLinc examples +# The following are some examples of scenes which could be used to setup an +# IOlinc. The details describe what commands sent to the scene would do. + +# Latching +# On and off commands to the scene will turn the relay on and off +# Momentary_A +# Only on commands will turn the relay on. +# Momentary_B +# Either on or off will turn on the relay. +# Momentary_C +# On commands will turn the relay on if the sensor is also on. +# Off commands will turn the relay on if the sensor is also off. +# All other commands would be ignored + +- name: 'iolinc_command' + controllers: + - modem + responders: + - iolinc + +# Latching +# On and off commands to the scene will turn the relay on and off +# Momentary_A +# Only off commands will turn the relay on. +# Momentary_B +# Either on or off will turn on the relay. +# Momentary_C +# On commands will turn the relay on if the sensor is off. +# Off commands will turn the relay on if the sensor is on. +# All other commands would be ignored + +- name: 'iolinc_command' + controllers: + - modem + responders: + - iolinc: + on_off: 0 \ No newline at end of file diff --git a/setup.py b/setup.py index a5bbf6be..e3d486a8 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setuptools.setup( name = 'insteon-mqtt', - version = '0.7.2', + version = '0.7.3', description = "Insteon <-> MQTT bridge server", long_description = readme, author = "Ted Drain", diff --git a/tests/db/test_Device.py b/tests/db/test_Device.py index b3780f40..16613e69 100644 --- a/tests/db/test_Device.py +++ b/tests/db/test_Device.py @@ -120,7 +120,7 @@ def test_add_multi_group(self): device = MockDevice() local_addr = IM.Address(0x01, 0x02, 0x03) - db = IM.db.Device(local_addr) + db = IM.db.Device(local_addr, device=device) # Add local group 1 as responder of scene 30 on remote. data = bytes([0xff, 0x00, 0x01]) @@ -128,8 +128,7 @@ def test_add_multi_group(self): remote_addr = IM.Address(0x50, 0x51, 0x52) remote_group = 0x30 - db.add_on_device(device, remote_addr, remote_group, is_controller, - data) + db.add_on_device(remote_addr, remote_group, is_controller, data) assert len(device.sent) == 2 assert len(db.entries) == 1 val0 = list(db.entries.values())[0] @@ -141,8 +140,7 @@ def test_add_multi_group(self): # Add again w/ a different local group data2 = bytes([0x50, 0x00, 0x02]) - db.add_on_device(device, remote_addr, remote_group, is_controller, - data2) + db.add_on_device(remote_addr, remote_group, is_controller, data2) assert len(db.entries) == 2 val1 = list(db.entries.values())[1] diff --git a/tests/device/test_IOLinc.py b/tests/device/test_IOLinc.py new file mode 100644 index 00000000..d0d15fc1 --- /dev/null +++ b/tests/device/test_IOLinc.py @@ -0,0 +1,391 @@ +#=========================================================================== +# +# Tests for: insteont_mqtt/device/IOLinc.py +# +#=========================================================================== +import pytest +from pprint import pprint +try: + import mock +except ImportError: + from unittest import mock +from unittest.mock import call +import insteon_mqtt as IM +import insteon_mqtt.device.IOLinc as IOLinc +import insteon_mqtt.message as Msg + + +@pytest.fixture +def test_iolinc(tmpdir): + ''' + Returns a generically configured iolinc for testing + ''' + protocol = MockProto() + modem = MockModem(tmpdir) + addr = IM.Address(0x01, 0x02, 0x03) + iolinc = IOLinc(protocol, modem, addr) + return iolinc + + +class Test_IOLinc_Simple(): + def test_pair(self, test_iolinc): + with mock.patch.object(IM.CommandSeq, 'add'): + test_iolinc.pair() + calls = [ + call(test_iolinc.refresh), + call(test_iolinc.db_add_resp_of, 0x01, test_iolinc.modem.addr, 0x01, + refresh=False), + call(test_iolinc.db_add_ctrl_of, 0x01, test_iolinc.modem.addr, 0x01, + refresh=False) + ] + IM.CommandSeq.add.assert_has_calls(calls) + assert IM.CommandSeq.add.call_count == 3 + + def test_get_flags(self, test_iolinc): + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_iolinc.get_flags() + args_list = IM.CommandSeq.add_msg.call_args_list + # Check that the first call is for standard flags + # Call#, Args, First Arg + assert args_list[0][0][0].cmd1 == 0x1f + # Check that the second call is for momentary timeout + assert args_list[1][0][0].cmd1 == 0x2e + assert IM.CommandSeq.add_msg.call_count == 2 + + def test_refresh(self, test_iolinc): + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_iolinc.refresh() + calls = IM.CommandSeq.add_msg.call_args_list + assert calls[0][0][0].cmd2 == 0x00 + assert calls[1][0][0].cmd2 == 0x01 + assert IM.CommandSeq.add_msg.call_count == 2 + + +class Test_IOLinc_Set_Flags(): + def test_set_flags_empty(self, test_iolinc): + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_iolinc.set_flags(None) + assert IM.CommandSeq.add_msg.call_count == 0 + + def test_set_flags_unknown(self, test_iolinc): + with pytest.raises(Exception): + test_iolinc.trigger_reverse = 0 + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_iolinc.set_flags(None, Unknown=1) + assert IM.CommandSeq.add_msg.call_count == 0 + + @pytest.mark.parametrize("mode,expected", [ + ("latching", [0x07, 0x13, 0x15]), + ("momentary_a", [0x06, 0x13, 0x15]), + ("momentary_b", [0x06, 0x12, 0x15]), + ("momentary_c", [0x06, 0x12, 0x14]), + ("bad-mode", [0x07, 0x13, 0x15]), + ]) + def test_set_flags_mode(self, test_iolinc, mode, expected): + self.mode = IM.device.IOLinc.Modes.LATCHING + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_iolinc.set_flags(None, mode=mode) + # Check that the first call is for standard flags + # Call#, Args, First Arg + calls = IM.CommandSeq.add_msg.call_args_list + for i in range(3): + assert calls[i][0][0].cmd1 == 0x20 + assert calls[i][0][0].cmd2 == expected[i] + assert IM.CommandSeq.add_msg.call_count == 3 + + @pytest.mark.parametrize("flag,expected", [ + ({"trigger_reverse": 0}, [0x20, 0x0f]), + ({"trigger_reverse": 1}, [0x20, 0x0e]), + ({"relay_linked": 0}, [0x20, 0x05]), + ({"relay_linked": 1}, [0x20, 0x04]), + ({"momentary_secs": .1}, [0x2e, 0x00, 0x01, 0x01]), + ({"momentary_secs": 26}, [0x2e, 0x00, 0x1a, 0x0a]), + ({"momentary_secs": 260}, [0x2e, 0x00, 0x1a, 0x64]), + ({"momentary_secs": 3000}, [0x2e, 0x00, 0x96, 0xc8]), + ({"momentary_secs": 6300}, [0x2e, 0x00, 0xfc, 0xfa]), + ]) + def test_set_flags_other(self, test_iolinc, flag, expected): + test_iolinc.momentary_secs = 0 + test_iolinc.relay_linked = 0 + test_iolinc.trigger_reverse = 0 + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_iolinc.set_flags(None, **flag) + # Check that the first call is for standard flags + # Call#, Args, First Arg + calls = IM.CommandSeq.add_msg.call_args_list + assert calls[0][0][0].cmd1 == expected[0] + assert calls[0][0][0].cmd2 == expected[1] + if len(expected) > 2: + assert calls[0][0][0].data[1] == 0x06 + assert calls[0][0][0].data[2] == expected[2] + assert calls[1][0][0].data[1] == 0x07 + assert calls[1][0][0].data[2] == expected[3] + assert IM.CommandSeq.add_msg.call_count == 2 + else: + assert IM.CommandSeq.add_msg.call_count == 1 + + +class Test_IOLinc_Set(): + @pytest.mark.parametrize("level,expected", [ + (0x00, 0x13), + (0x01, 0x11), + (0xff, 0x11), + ]) + def test_set(self, test_iolinc, level, expected): + with mock.patch.object(IM.device.Base, 'send'): + test_iolinc.set(level) + calls = IM.device.Base.send.call_args_list + assert calls[0][0][0].cmd1 == expected + assert IM.device.Base.send.call_count == 1 + + @pytest.mark.parametrize("is_on,expected", [ + (True, True), + (False, False), + ]) + def test_sensor_on(self, test_iolinc, is_on, expected): + with mock.patch.object(IM.Signal, 'emit'): + test_iolinc._set_sensor_is_on(is_on) + calls = IM.Signal.emit.call_args_list + assert calls[0][0][1] == expected + assert IM.Signal.emit.call_count == 1 + + @pytest.mark.parametrize("is_on, mode, moment, relay, add, remove", [ + (True, IM.device.IOLinc.Modes.LATCHING, False, True, 0, 0), + (True, IM.device.IOLinc.Modes.MOMENTARY_A, False, True, 1, 0), + (True, IM.device.IOLinc.Modes.MOMENTARY_A, False, True, 1, 1), + (False, IM.device.IOLinc.Modes.MOMENTARY_A, False, False, 0, 0), + (False, IM.device.IOLinc.Modes.MOMENTARY_A, True, False, 0, 0), + (False, IM.device.IOLinc.Modes.MOMENTARY_A, True, False, 0, 1), + ]) + def test_relay_on(self, test_iolinc, is_on, mode, moment, relay, + add, remove): + with mock.patch.object(IM.Signal, 'emit'): + with mock.patch.object(test_iolinc.modem.timed_call, 'add'): + with mock.patch.object(test_iolinc.modem.timed_call, 'remove'): + test_iolinc.mode = mode + if remove > 0: + test_iolinc._momentary_call = True + test_iolinc._set_relay_is_on(is_on, momentary=moment) + emit_calls = IM.Signal.emit.call_args_list + assert emit_calls[0][0][2] == relay + assert IM.Signal.emit.call_count == 1 + assert test_iolinc.modem.timed_call.add.call_count == add + assert test_iolinc.modem.timed_call.remove.call_count == remove + +class Test_Handles(): + @pytest.mark.parametrize("linked,cmd1,sensor,relay", [ + (False, 0x11, True, None), + (True, 0x11, True, True), + (False, 0x13, False, None), + (True, 0x13, False, False), + (False, 0x06, None, None), + ]) + def test_handle_broadcast(self, test_iolinc, linked, cmd1, sensor, + relay): + with mock.patch.object(IM.Signal, 'emit'): + test_iolinc.relay_linked = linked + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(IM.message.Flags.Type.BROADCAST, False) + cmd2 = 0x00 + msg = IM.message.InpStandard(from_addr, to_addr, flags, cmd1, cmd2) + test_iolinc.handle_broadcast(msg) + calls = IM.Signal.emit.call_args_list + if linked: + assert calls[1][0][2] == relay + assert IM.Signal.emit.call_count == 2 + elif sensor is not None: + assert calls[0][0][1] == sensor + assert IM.Signal.emit.call_count == 1 + else: + assert IM.Signal.emit.call_count == 0 + + @pytest.mark.parametrize("cmd2,mode,relay,reverse", [ + (0x00, IM.device.IOLinc.Modes.LATCHING, False, False), + (0X0c, IM.device.IOLinc.Modes.MOMENTARY_A, True, False), + (0x5c, IM.device.IOLinc.Modes.MOMENTARY_B, True, True), + (0xd8, IM.device.IOLinc.Modes.MOMENTARY_C, False, True), + ]) + def test_handle_flags(self, test_iolinc, cmd2, mode, relay, + reverse): + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) + cmd1 = 0x1f + msg = IM.message.InpStandard(from_addr, to_addr, flags, cmd1, cmd2) + test_iolinc.handle_flags(msg, lambda success, msg, cmd: True) + assert test_iolinc.mode == mode + assert test_iolinc.relay_linked == relay + assert test_iolinc.trigger_reverse == reverse + + @pytest.mark.parametrize("time_val, multiplier, seconds", [ + (0x01, 0x01, .1), + (0x1a, 0x0a, 26), + (0x1a, 0x64, 260), + (0x96, 0xc8, 3000), + (0xfc, 0xfa, 6300), + ]) + def test_handle_momentary(self, test_iolinc, time_val, multiplier, + seconds): + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(IM.message.Flags.Type.DIRECT, True) + data = bytes([0x00] * 2 + [multiplier, time_val] + [0x00] * 10) + msg = IM.message.InpExtended(from_addr, to_addr, flags, 0x2e, 0x00, + data) + test_iolinc.handle_get_momentary(msg, lambda success, msg, cmd: True) + assert test_iolinc.momentary_secs == seconds + + def test_handle_set_flags(self, test_iolinc): + # Dummy Test, nothing to do here + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) + msg = IM.message.InpStandard(from_addr, to_addr, flags, 0x00, 0x00) + test_iolinc.handle_set_flags(msg, lambda success, msg, cmd: True) + assert True == True + + @pytest.mark.parametrize("cmd2,expected", [ + (0x00, False), + (0Xff, True), + ]) + def test_handle_refresh_relay(self, test_iolinc, cmd2, expected): + with mock.patch.object(IM.Signal, 'emit'): + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) + msg = IM.message.InpStandard(from_addr, to_addr, flags, 0x19, cmd2) + test_iolinc.handle_refresh_relay(msg) + calls = IM.Signal.emit.call_args_list + assert calls[0][0][2] == expected + assert IM.Signal.emit.call_count == 1 + + @pytest.mark.parametrize("cmd2,expected", [ + (0x00, False), + (0Xff, True), + ]) + def test_handle_refresh_sensor(self, test_iolinc, cmd2, expected): + with mock.patch.object(IM.Signal, 'emit'): + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) + msg = IM.message.InpStandard(from_addr, to_addr, flags, 0x19, cmd2) + test_iolinc.handle_refresh_sensor(msg) + calls = IM.Signal.emit.call_args_list + assert calls[0][0][1] == expected + assert IM.Signal.emit.call_count == 1 + + @pytest.mark.parametrize("cmd1, type, expected", [ + (0x11, IM.message.Flags.Type.DIRECT_ACK, True), + (0X13, IM.message.Flags.Type.DIRECT_ACK, False), + (0X11, IM.message.Flags.Type.DIRECT_NAK, None), + ]) + def test_handle_ack(self, test_iolinc, cmd1, type, expected): + with mock.patch.object(IM.Signal, 'emit'): + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(type, False) + msg = IM.message.InpStandard(from_addr, to_addr, flags, cmd1, 0x01) + test_iolinc.handle_ack(msg, lambda success, msg, cmd: True) + calls = IM.Signal.emit.call_args_list + if expected is not None: + assert calls[0][0][2] == expected + assert IM.Signal.emit.call_count == 1 + else: + assert IM.Signal.emit.call_count == 0 + + @pytest.mark.parametrize("cmd1, entry_d1, mode, sensor, expected", [ + (0x11, None, IM.device.IOLinc.Modes.LATCHING, False, None), + (0x11, 0xFF, IM.device.IOLinc.Modes.LATCHING, False, True), + (0x13, 0xFF, IM.device.IOLinc.Modes.LATCHING, False, False), + (0x11, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_A, False, True), + (0x13, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_A, False, False), + (0x11, 0x00, IM.device.IOLinc.Modes.MOMENTARY_A, False, False), + (0x13, 0x00, IM.device.IOLinc.Modes.MOMENTARY_A, False, True), + (0x11, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_B, False, True), + (0x13, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_B, False, True), + (0x11, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_C, False, False), + (0x13, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_C, False, True), + (0x11, 0X00, IM.device.IOLinc.Modes.MOMENTARY_C, False, True), + (0x13, 0X00, IM.device.IOLinc.Modes.MOMENTARY_C, False, False), + (0x11, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_C, True, True), + (0x13, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_C, True, False), + (0xFF, 0xFF, IM.device.IOLinc.Modes.MOMENTARY_C, True, None), + ]) + def test_handle_group_cmd(self, test_iolinc, cmd1, entry_d1, mode, + sensor, expected): + # We null out the TimedCall feature with a Mock class below. We could + # test here, but I wrote a specific test of the set functions instead + # Attach to signal sent to MQTT + with mock.patch.object(IM.Signal, 'emit'): + # Set the device in the requested states + test_iolinc._sensor_is_on = sensor + test_iolinc.mode = mode + # Build the msg to send to the handler + to_addr = test_iolinc.addr + from_addr = IM.Address(0x04, 0x05, 0x06) + flags = IM.message.Flags(IM.message.Flags.Type.ALL_LINK_CLEANUP, + False) + msg = IM.message.InpStandard(from_addr, to_addr, flags, cmd1, 0x01) + # If db entry is requested, build and add the entry to the dev db + if entry_d1 is not None: + db_flags = IM.message.DbFlags(True, False, True) + entry = IM.db.DeviceEntry(from_addr, 0x01, 0xFFFF, db_flags, + bytes([entry_d1, 0x00, 0x00])) + test_iolinc.db.add_entry(entry) + # send the message to the handler + test_iolinc.handle_group_cmd(from_addr, msg) + # Test the responses received + calls = IM.Signal.emit.call_args_list + if expected is not None: + assert calls[0][0][2] == expected + assert IM.Signal.emit.call_count == 1 + else: + assert IM.Signal.emit.call_count == 0 + + +class Test_IOLinc_Link_Data: + @pytest.mark.parametrize("data_1, pretty_data_1, name, is_controller", [ + (0x00, 0, 'on_off', False), + (0xFF, 1, 'on_off', False), + (0xFF, 0XFF, 'data_1', True), + ]) + def test_link_data(self, test_iolinc, data_1, pretty_data_1, name, + is_controller): + pretty = test_iolinc.link_data_to_pretty(is_controller, + [data_1, 0x00, 0x00]) + assert pretty[0][name] == pretty_data_1 + ugly = test_iolinc.link_data_from_pretty(is_controller, + {name: pretty_data_1, + 'data_2': 0x00, + 'data_3': 0x00}) + assert ugly[0] == data_1 + + +class MockModem: + def __init__(self, path): + self.save_path = str(path) + self.addr = IM.Address(0x0A, 0x0B, 0x0C) + self.timed_call = MockTimedCall() + + +class MockTimedCall: + def add(self, *args, **kwargs): + pass + + def remove(self, *args, **kwargs): + pass + +class MockProto: + def __init__(self): + self.msgs = [] + self.wait = None + + def add_handler(self, *args): + pass + + def send(self, msg, msg_handler, high_priority=False, after=None): + self.msgs.append(msg) + + def set_wait_time(self, time): + self.wait = time diff --git a/tests/handler/test_Broadcast.py b/tests/handler/test_Broadcast.py index 2c1f82db..67e571cc 100644 --- a/tests/handler/test_Broadcast.py +++ b/tests/handler/test_Broadcast.py @@ -12,7 +12,7 @@ class Test_Broadcast: def test_acks(self, tmpdir): proto = MockProto() calls = [] - modem = IM.Modem(proto, IM.network.Stack()) + modem = IM.Modem(proto, IM.network.Stack(), IM.network.TimedCall()) modem.save_path = str(tmpdir) addr = IM.Address('0a.12.34') diff --git a/tests/handler/test_ModemDbGet.py b/tests/handler/test_ModemDbGet.py index ada85741..a3899f92 100644 --- a/tests/handler/test_ModemDbGet.py +++ b/tests/handler/test_ModemDbGet.py @@ -6,6 +6,7 @@ #=========================================================================== import insteon_mqtt as IM import insteon_mqtt.message as Msg +import helpers as H class Test_ModemDbGet: @@ -44,6 +45,7 @@ def callback(success, msg, done): calls.append(msg) db = Mockdb() + db.device = MockDevice() handler = IM.handler.ModemDbGet(db, callback) proto = MockProtocol() @@ -57,8 +59,8 @@ def callback(success, msg, done): msg.db_flags.is_controller, msg.data) r = handler.msg_received(proto, msg) assert r == Msg.FINISHED - assert isinstance(proto.sent, Msg.OutAllLinkGetNext) - assert proto.handler == handler + assert isinstance(db.device.sent[0]['msg'], Msg.OutAllLinkGetNext) + assert db.device.sent[0]['handler'] == handler assert db.entry == test_entry #=========================================================================== @@ -76,3 +78,12 @@ def save(self): def add_entry(self, entry): self.entry = entry + +class MockDevice: + """Mock insteon_mqtt/Device class + """ + def __init__(self): + self.sent = [] + + def send(self, msg, handler, priority=None, after=None): + self.sent.append(H.Data(msg=msg, handler=handler)) diff --git a/tests/mqtt/test_IOLinc.py b/tests/mqtt/test_IOLincMqtt.py similarity index 65% rename from tests/mqtt/test_IOLinc.py rename to tests/mqtt/test_IOLincMqtt.py index f1cf45ee..6fc05bd4 100644 --- a/tests/mqtt/test_IOLinc.py +++ b/tests/mqtt/test_IOLincMqtt.py @@ -46,18 +46,14 @@ def test_pubsub(self, setup): mdev, addr, link = setup.getAll(['mdev', 'addr', 'link']) mdev.subscribe(link, 2) - assert len(link.client.sub) == 2 + assert len(link.client.sub) == 1 assert link.client.sub[0] == dict( topic='insteon/%s/set' % addr.hex, qos=2) - assert link.client.sub[1] == dict( - topic='insteon/%s/scene' % addr.hex, qos=2) mdev.unsubscribe(link) - assert len(link.client.unsub) == 2 + assert len(link.client.unsub) == 1 assert link.client.unsub[0] == dict( topic='insteon/%s/set' % addr.hex) - assert link.client.unsub[1] == dict( - topic='insteon/%s/scene' % addr.hex) #----------------------------------------------------------------------- def test_template(self, setup): @@ -67,14 +63,16 @@ def test_template(self, setup): right = {"address" : addr.hex, "name" : name} assert data == right - data = mdev.template_data(is_on=True) + data = mdev.template_data(relay_is_on=True, sensor_is_on=True) right = {"address" : addr.hex, "name" : name, - "on" : 1, "on_str" : "on"} + "relay_on" : 1, "relay_on_str" : "on", + "sensor_on" : 1, "sensor_on_str" : "on"} assert data == right - data = mdev.template_data(is_on=False) + data = mdev.template_data(relay_is_on=False, sensor_is_on=False) right = {"address" : addr.hex, "name" : name, - "on" : 0, "on_str" : "off"} + "relay_on" : 0, "relay_on_str" : "off", + "sensor_on" : 0, "sensor_on_str" : "off"} assert data == right #----------------------------------------------------------------------- @@ -86,13 +84,34 @@ def test_mqtt(self, setup): mdev.load_config({}) # Send an on/off signal - dev.signal_on_off.emit(dev, True) - dev.signal_on_off.emit(dev, False) - assert len(link.client.pub) == 2 + dev.signal_on_off.emit(dev, True, True) + dev.signal_on_off.emit(dev, False, False) + # There are three topics per message state, relay, sensor + assert len(link.client.pub) == 6 assert link.client.pub[0] == dict( - topic='%s/state' % topic, payload='on', qos=0, retain=True) + topic='%s/state' % topic, + payload='{"sensor": "on", "relay": "on"}', + qos=0, retain=True) assert link.client.pub[1] == dict( - topic='%s/state' % topic, payload='off', qos=0, retain=True) + topic='%s/relay' % topic, + payload='on', + qos=0, retain=True) + assert link.client.pub[2] == dict( + topic='%s/sensor' % topic, + payload='on', + qos=0, retain=True) + assert link.client.pub[3] == dict( + topic='%s/state' % topic, + payload='{"sensor": "off", "relay": "off"}', + qos=0, retain=True) + assert link.client.pub[4] == dict( + topic='%s/relay' % topic, + payload='off', + qos=0, retain=True) + assert link.client.pub[5] == dict( + topic='%s/sensor' % topic, + payload='off', + qos=0, retain=True) link.client.clear() #----------------------------------------------------------------------- @@ -101,20 +120,33 @@ def test_config(self, setup): config = {'io_linc' : { 'state_topic' : 'foo/{{address}}', - 'state_payload' : '{{on}} {{on_str.upper()}}'}} + 'state_payload' : '{{relay_on}} {{relay_on_str.upper()}}', + 'relay_state_topic' : 'foo/{{address}}/relay', + 'relay_state_payload' : '{{relay_on}} {{relay_on_str.upper()}}', + 'sensor_state_topic' : 'foo/{{address}}/sensor', + 'sensor_state_payload' : '{{sensor_on}} {{sensor_on_str.upper()}}' + }} qos = 3 mdev.load_config(config, qos) stopic = "foo/%s" % setup.addr.hex # Send an on/off signal - dev.signal_on_off.emit(dev, True) - dev.signal_on_off.emit(dev, False) - assert len(link.client.pub) == 2 + dev.signal_on_off.emit(dev, True, True) + dev.signal_on_off.emit(dev, False, False) + assert len(link.client.pub) == 6 assert link.client.pub[0] == dict( topic=stopic, payload='1 ON', qos=qos, retain=True) assert link.client.pub[1] == dict( + topic=stopic + "/relay", payload='1 ON', qos=qos, retain=True) + assert link.client.pub[2] == dict( + topic=stopic + "/sensor", payload='1 ON', qos=qos, retain=True) + assert link.client.pub[3] == dict( topic=stopic, payload='0 OFF', qos=qos, retain=True) + assert link.client.pub[4] == dict( + topic=stopic + "/relay", payload='0 OFF', qos=qos, retain=True) + assert link.client.pub[5] == dict( + topic=stopic + "/sensor", payload='0 OFF', qos=qos, retain=True) link.client.clear() #----------------------------------------------------------------------- @@ -148,35 +180,5 @@ def test_input_on_off(self, setup): # test error payload link.publish(topic, b'asdf', qos, False) - #----------------------------------------------------------------------- - def test_input_scene(self, setup): - mdev, link, modem, addr = setup.getAll(['mdev', 'link', 'modem', - 'addr']) - - qos = 2 - config = {'io_linc' : { - 'scene_topic' : 'foo/{{address}}', - 'scene_payload' : '{ "cmd" : "{{json.on.lower()}}" }'}} - mdev.load_config(config, qos=qos) - - mdev.subscribe(link, qos) - topic = 'foo/%s' % addr.hex - - payload = b'{ "on" : "OFF" }' - link.publish(topic, payload, qos, retain=False) - assert len(modem.scenes) == 1 - assert modem.scenes[0][0] == 0 # is_on - assert modem.scenes[0][1] == 50 # group - modem.scenes = [] - - payload = b'{ "on" : "ON" }' - link.publish(topic, payload, qos, retain=False) - assert modem.scenes[0][0] == 1 # is_on - assert modem.scenes[0][1] == 50 # group - modem.scenes = [] - - # test error payload - link.publish(topic, b'asdf', qos, False) - #=========================================================================== diff --git a/tests/mqtt/test_Leak.py b/tests/mqtt/test_Leak.py index bce98d4b..79bbe533 100644 --- a/tests/mqtt/test_Leak.py +++ b/tests/mqtt/test_Leak.py @@ -60,16 +60,14 @@ def test_template(self, setup): assert data == right t0 = time.time() - data = mdev.template_data(is_wet=True, is_heartbeat=True) + data = mdev.template_data(is_heartbeat=True) right = {"address" : addr.hex, "name" : name, - "is_wet" : 1, "is_wet_str" : "on", "state" : "wet", - "is_dry" : 0, "is_dry_str" : "off", "is_heartbeat" : 1, "is_heartbeat_str" : "on"} hb = data.pop('heartbeat_time') assert data == right pytest.approx(t0, hb, 5) - data = mdev.template_data(is_wet=False) + data = mdev.template_data_leak(is_wet=False) right = {"address" : addr.hex, "name" : name, "is_wet" : 0, "is_wet_str" : "off", "state" : "dry", "is_dry" : 1, "is_dry_str" : "on"} diff --git a/tests/mqtt/test_Modem.py b/tests/mqtt/test_Modem.py index 631c37b4..28571885 100644 --- a/tests/mqtt/test_Modem.py +++ b/tests/mqtt/test_Modem.py @@ -24,7 +24,7 @@ @pytest.fixture def setup(mock_paho_mqtt, tmpdir): proto = H.main.MockProtocol() - modem = IM.Modem(proto, IM.network.Stack()) + modem = IM.Modem(proto, IM.network.Stack(), IM.network.TimedCall()) modem.name = "modem" modem.addr = IM.Address(0x20, 0x30, 0x40) diff --git a/tests/mqtt/test_Remote.py b/tests/mqtt/test_Remote.py index 86b1a1cd..05dfce7e 100644 --- a/tests/mqtt/test_Remote.py +++ b/tests/mqtt/test_Remote.py @@ -55,25 +55,26 @@ def test_pubsub(self, setup): def test_template(self, setup): mdev, addr, name = setup.getAll(['mdev', 'addr', 'name']) - data = mdev.template_data(3) + data = mdev.template_data_remote(3) right = {"address" : addr.hex, "name" : name, "button" : 3} assert data == right - data = mdev.template_data(4, is_on=True, mode=IM.on_off.Mode.FAST, - manual=IM.on_off.Manual.STOP) + data = mdev.template_data_remote(4, is_on=True, + mode=IM.on_off.Mode.FAST, + manual=IM.on_off.Manual.STOP) right = {"address" : addr.hex, "name" : name, "button" : 4, "on" : 1, "on_str" : "on", "mode" : "fast", "fast" : 1, "instant" : 0, "manual_str" : "stop", "manual" : 0, "manual_openhab" : 1} assert data == right - data = mdev.template_data(4, is_on=False) + data = mdev.template_data_remote(4, is_on=False) right = {"address" : addr.hex, "name" : name, "button" : 4, "on" : 0, "on_str" : "off", "mode" : "normal", "fast" : 0, "instant" : 0} assert data == right - data = mdev.template_data(5, manual=IM.on_off.Manual.UP) + data = mdev.template_data_remote(5, manual=IM.on_off.Manual.UP) right = {"address" : addr.hex, "name" : name, "button" : 5, "manual_str" : "up", "manual" : 1, "manual_openhab" : 2} assert data == right diff --git a/tests/mqtt/test_config.py b/tests/mqtt/test_config.py index 0a1d5ad8..8138dd1e 100644 --- a/tests/mqtt/test_config.py +++ b/tests/mqtt/test_config.py @@ -29,7 +29,7 @@ def test_find(self, tmpdir): instances.append(inst) types.append("Modem") - inst = IM.Modem(proto, IM.network.Stack()) + inst = IM.Modem(proto, IM.network.Stack(), IM.network.TimedCall()) instances.append(inst) for i in range(len(types)): diff --git a/tests/util/helpers/main.py b/tests/util/helpers/main.py index 981b1e7b..674e3f9c 100644 --- a/tests/util/helpers/main.py +++ b/tests/util/helpers/main.py @@ -27,6 +27,7 @@ class MockProtocol: """ def __init__(self): self.signal_received = IM.Signal() + self.signal_msg_finished = IM.Signal() self.sent = [] def clear(self):