From 0935e6e8db4e4dcda5d4a4f09d06566bb0462965 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Mon, 2 Mar 2020 11:04:51 -0800 Subject: [PATCH 01/30] Fix IOLinc Set Flags; Store Flags in DB; Track Relay and Sensor Significant improvements to the IOLinc: - Tracks the state of both the sensor and the relay. - Changes the MQTT Payload to contain the sensor and relay states by default. - Enables the writing of flags to the device. This design matches how it is defined in the Insteon Documentation and also matches how the Insteon App handles this. The old code did not set any flags for me. I would be surprised if it worked on any device. - Tracks the flags in persistent storage. This enables things like momentary changes and linked to relay to be enabled by future work. --- config.yaml | 4 +- docs/mqtt.md | 5 + insteon_mqtt/device/IOLinc.py | 548 +++++++++++++++++++++++++++------- insteon_mqtt/mqtt/IOLinc.py | 20 +- 4 files changed, 465 insertions(+), 112 deletions(-) diff --git a/config.yaml b/config.yaml index 3826a190..c3ee3961 100644 --- a/config.yaml +++ b/config.yaml @@ -52,7 +52,7 @@ insteon: startup_refresh: False # Path to Scenes Definition file (Optional) - # The path can be specified either as an absolute path or as a relative path + # The path can be specified either as an absolute path or as a relative path # using the !rel_path directive. Where the path is relative to the # config.yaml location # @@ -845,7 +845,7 @@ mqtt: # on = 0/1 # 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()}} }' # 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. diff --git a/docs/mqtt.md b/docs/mqtt.md index 8be6f0b2..d84d6846 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -477,6 +477,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: diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index e4e52593..aa46adbd 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -4,11 +4,13 @@ # #=========================================================================== import functools +import enum 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 @@ -131,6 +133,38 @@ class IOLinc(Base): """ 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,7 +178,9 @@ 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 # Support on/off style signals for the sensor # API: func(Device, bool is_on) @@ -168,6 +204,118 @@ def __init__(self, protocol, modem, address, name=None): '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: + ret = meta['mode'] + 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} + existing = self.db.get_meta('IOLinc') + if isinstance(existing, dict) and 'mode' in meta: + 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) and 'trigger_reverse' in meta: + 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) and 'relay_linked' in meta: + 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 = False + 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) and 'momentary_secs' in meta: + 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. @@ -223,6 +371,40 @@ def pair(self, on_done=None): # 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 +430,111 @@ 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 " "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) - - # 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) + 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': + mode = IOLinc.Modes[kwargs[flag].upper()] + # 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 - # 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 +548,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 @@ -343,9 +567,17 @@ def refresh(self, force=False, on_done=None): # 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,13 +587,20 @@ def refresh(self, force=False, on_done=None): seq.run() #----------------------------------------------------------------------- - def is_on(self): - """Return if the device is on or not. + def sensor_is_on(self): + """Return if the device sensor is on or not. + """ + return self._sensor_is_on + + #----------------------------------------------------------------------- + def relay_is_on(self): + """Return if the device relay is on or not. """ - return self._is_on + return self._relay_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 @@ -399,11 +638,12 @@ 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. + This turns the relay off no matter what. It ignores the momentary + A/B/C settings and just turns the relay off. 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 @@ -466,12 +706,12 @@ def set(self, level, group=0x01, instant=False, on_done=None): self.off(group, instant, on_done) #----------------------------------------------------------------------- - def scene(self, is_on, group=None, on_done=None): + def scene(self, is_on, group=None, reason="", 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. + device. It will change the state of the device relay and notify + responders that are linked to the device to be updated. Args: is_on (bool): True for an on command, False for an off command. @@ -510,7 +750,13 @@ 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 +774,12 @@ 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) # 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) # This will find all the devices we're the controller of for # this group and call their handle_group_cmd() methods to @@ -543,9 +789,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,15 +825,21 @@ 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. @@ -595,23 +847,84 @@ def handle_flags(self, msg, on_done): 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 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. + + time = (msg.data[3] * msg.data[2]) / 10 + self.momentary_secs = time + LOG.ui("Momentary Secs : %s", time) + 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. + """ + # TODO decode 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 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 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): @@ -622,18 +935,29 @@ def handle_ack(self, msg, on_done): 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. + 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) + # On command. 0x11: on + if msg.cmd1 == 0x11: + LOG.info("IOLinc %s relay ON", self.addr) + self._set_relay_is_on(True) + + # 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) @@ -690,25 +1014,45 @@ 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) + 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_sensor_is_on(self, is_on, reason=""): + """Update the device sensor on/off 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_is_on(self, is_on): - """Update the device on/off state. + def _set_relay_is_on(self, is_on, reason=""): + """Update the device relay 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 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. """ - LOG.info("Setting device %s on %s", self.label, is_on) - self._is_on = bool(is_on) + LOG.info("Setting device %s relay on %s", self.label, is_on) + self._relay_is_on = bool(is_on) - self.signal_on_off.emit(self, self._is_on) + self.signal_on_off.emit(self, self._sensor_is_on, self._relay_is_on) #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/IOLinc.py b/insteon_mqtt/mqtt/IOLinc.py index 5f373351..834e3be5 100644 --- a/insteon_mqtt/mqtt/IOLinc.py +++ b/insteon_mqtt/mqtt/IOLinc.py @@ -30,7 +30,7 @@ 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()}} }') # Input on/off command template. self.msg_on_off = MsgTemplate( @@ -95,7 +95,7 @@ def unsubscribe(self, link): 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 +112,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,9 +132,10 @@ 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) #----------------------------------------------------------------------- From cfab4f4e3c88d6c7f460c04e96081ebe97d0f814 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Tue, 3 Mar 2020 11:37:56 -0800 Subject: [PATCH 02/30] Add TimedCall; Enables Scheduling Functional Calls Relatively simple design. Does not guarantee accuracy. Only that the call will not be made prior to the scheduled time. --- insteon_mqtt/Modem.py | 5 +- insteon_mqtt/cmd_line/start.py | 4 +- insteon_mqtt/network/TimedCall.py | 134 ++++++++++++++++++++++++++++++ insteon_mqtt/network/__init__.py | 1 + 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 insteon_mqtt/network/TimedCall.py diff --git a/insteon_mqtt/Modem.py b/insteon_mqtt/Modem.py index e838713c..2ba83ef0 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" 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/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 From c4a5beaf2e12eb471bb1a75bf8dda5c1f3fe2187 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Tue, 3 Mar 2020 11:48:21 -0800 Subject: [PATCH 03/30] Track Relay State Internally Using Values from Device Flags Turns off the relay after momentary_secs if momentary mode is enabled. There is a slight delay, as the TimedCall feature is not a true asynch system, but it is very close and I think within reasonable expectations. Only turns on the relay if the proper Momentary conditions are met. Turn on relay if relay_linked is set and sensor is activated. I believe this completes the modeling of the IOLinc in software. With the exception of the "Set Button" all interactions accurately track the IOLinc. --- insteon_mqtt/device/IOLinc.py | 82 ++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index aa46adbd..8170dba0 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -5,6 +5,7 @@ #=========================================================================== import functools import enum +import time from .Base import Base from ..CommandSeq import CommandSeq from .. import handler @@ -182,6 +183,10 @@ def __init__(self, protocol, modem, address, name=None): 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() @@ -295,7 +300,7 @@ def momentary_secs(self): """Returns the momentary seconds from the saved metadata """ meta = self.db.get_meta('IOLinc') - ret = False + 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 @@ -705,6 +710,8 @@ 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, reason="", on_done=None): """Trigger a scene on the device. @@ -775,11 +782,17 @@ def handle_broadcast(self, msg): elif msg.cmd1 == 0x11: LOG.info("IOLinc %s broadcast ON grp: %s", self.addr, msg.group) 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_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 @@ -930,12 +943,19 @@ def handle_refresh_sensor(self, msg): 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. + 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. @@ -964,7 +984,7 @@ def handle_ack(self, msg, on_done): on_done(False, "IOLinc command failed. " + msg.nak_str(), None) #----------------------------------------------------------------------- - def handle_scene(self, msg, on_done): + def handle_scene(self, msg, on_done, reason=''): """Callback for scene simulation commanded messages. This callback is run when we get a reply back from triggering a scene @@ -1018,6 +1038,30 @@ def handle_group_cmd(self, addr, msg): # 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, @@ -1040,7 +1084,7 @@ def _set_sensor_is_on(self, is_on, reason=""): self.signal_on_off.emit(self, self._sensor_is_on, self._relay_is_on) #----------------------------------------------------------------------- - def _set_relay_is_on(self, is_on, reason=""): + 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 @@ -1049,10 +1093,36 @@ def _set_relay_is_on(self, is_on, reason=""): 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 """ - LOG.info("Setting device %s relay on %s", self.label, is_on) + 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 + + + #----------------------------------------------------------------------- From 1065619039380a8a817d736cae9609b0170f0868 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Tue, 3 Mar 2020 14:02:42 -0800 Subject: [PATCH 04/30] Remove Scene Option from IOLinc I don't think hijacking the simulated scene call and translating it into a modem scene was a good idea. - It required creating a scene that would get picked up by import_scenes and could confuse users. - It was unnecessary for many use cases. - It still didn't properly reflect all use cases as it didn't allow the user to specify the on_level for D1 and so therefore Momentary_A and Momemtary_C uses may not have been correct. I think it is better to redirect users to create a proper scene for their device and to provide them with the documentation on how to do that. --- insteon_mqtt/device/IOLinc.py | 88 ----------------------------------- 1 file changed, 88 deletions(-) diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index 8170dba0..fc09e77a 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -191,21 +191,12 @@ def __init__(self, protocol, modem, address, name=None): # 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, }) @@ -361,16 +352,6 @@ 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. @@ -710,48 +691,6 @@ 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, reason="", 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 relay and notify - responders that are linked to 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. @@ -983,33 +922,6 @@ def handle_ack(self, msg, on_done): msg.nak_str(), msg) on_done(False, "IOLinc command failed. " + msg.nak_str(), None) - #----------------------------------------------------------------------- - def handle_scene(self, msg, on_done, reason=''): - """Callback for scene simulation commanded messages. - - 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) - - 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) - #----------------------------------------------------------------------- def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. From d87ea8350e90317adec2f657db37451832c06a82 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Tue, 3 Mar 2020 14:28:29 -0800 Subject: [PATCH 05/30] Clean Up IOLinc Comments --- insteon_mqtt/device/IOLinc.py | 183 ++++++++++++---------------------- 1 file changed, 62 insertions(+), 121 deletions(-) diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index fc09e77a..5655c1d2 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -28,109 +28,57 @@ 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" @@ -590,12 +538,11 @@ def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", """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 @@ -629,12 +576,11 @@ def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", """Turn the relay off. This turns the relay off no matter what. It ignores the momentary - A/B/C settings and just turns the relay off. - - 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 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 @@ -664,12 +610,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 @@ -792,9 +737,6 @@ def handle_flags(self, msg, on_done): # 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) @@ -821,9 +763,9 @@ def handle_get_momentary(self, msg, on_done): # 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. - time = (msg.data[3] * msg.data[2]) / 10 - self.momentary_secs = time - LOG.ui("Momentary Secs : %s", time) + seconds = (msg.data[3] * msg.data[2]) / 10 + self.momentary_secs = seconds + LOG.ui("Momentary Secs : %s", seconds) on_done(True, "Operation complete", None) #----------------------------------------------------------------------- @@ -836,7 +778,6 @@ def handle_set_flags(self, msg, on_done): msg (message.InpStandard): The refresh message reply. The msg.cmd2 field represents the flag that was set. """ - # TODO decode flag that was set LOG.info("IOLinc Set Flag=%s", msg.cmd2) on_done(True, "Operation complete", msg.cmd2) From f670ad184b9477eb17dba46d4ca53515bb497bdf Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Tue, 3 Mar 2020 15:41:44 -0800 Subject: [PATCH 06/30] Add IOLinc Documentation; Add IOLinc Topics for Sensor and Relay --- config.yaml | 50 +++++++++++++------- docs/mqtt.md | 89 +++++++++++++++++++++++++++++++++++ insteon_mqtt/device/IOLinc.py | 52 ++++++++++++++++++++ insteon_mqtt/mqtt/IOLinc.py | 58 +++++++---------------- scenes.yaml | 39 +++++++++++++++ 5 files changed, 232 insertions(+), 56 deletions(-) diff --git a/config.yaml b/config.yaml index c3ee3961..57b1c2a2 100644 --- a/config.yaml +++ b/config.yaml @@ -826,10 +826,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: @@ -838,17 +841,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: '{ "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: @@ -860,13 +885,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 d84d6846..c82bb3a5 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 @@ -1271,3 +1272,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/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index 5655c1d2..5591de11 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -976,6 +976,58 @@ def _set_relay_is_on(self, is_on, reason="", momentary=False): 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 + """ + 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. + 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/mqtt/IOLinc.py b/insteon_mqtt/mqtt/IOLinc.py index 834e3be5..2a6dbe26 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='{ "sensor" : "{{sensor_on_str.lower()}}"", relay" : {{relay_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,9 +96,6 @@ 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, sensor_is_on=None, relay_is_on=None): """Create the Jinja templating data variables for on/off messages. @@ -137,6 +139,8 @@ def _insteon_on_off(self, device, sensor_is_on, relay_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): @@ -165,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/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 From 883313ad3d9560ec8d6a550ce10a2b8dcbc67a70 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Tue, 3 Mar 2020 17:55:07 -0800 Subject: [PATCH 07/30] Fix and Update PyTest Tests --- config.yaml | 4 +- insteon_mqtt/device/IOLinc.py | 3 +- insteon_mqtt/mqtt/IOLinc.py | 4 +- tests/handler/test_Broadcast.py | 2 +- tests/mqtt/test_IOLinc.py | 100 ++++++++++++++++---------------- tests/mqtt/test_Modem.py | 2 +- tests/mqtt/test_config.py | 2 +- 7 files changed, 59 insertions(+), 58 deletions(-) diff --git a/config.yaml b/config.yaml index 57b1c2a2..e9a5ee98 100644 --- a/config.yaml +++ b/config.yaml @@ -831,7 +831,7 @@ mqtt: # 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 + # 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: @@ -850,7 +850,7 @@ mqtt: # sensor_on_str = 'off'/'on' # relay_on_str = 'off'/'on' state_topic: 'insteon/{{address}}/state' - state_payload: '{ "sensor" : "{{sensor_on_str.lower()}}"", relay" : {{relay_on_str.lower()}} }' + 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 diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index 5591de11..68f62ee1 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -3,7 +3,6 @@ # Insteon on/off device # #=========================================================================== -import functools import enum import time from .Base import Base @@ -459,7 +458,7 @@ def set_flags(self, on_done, **kwargs): # set the multiple msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, - bytes([0x00, 0x07, multiple,] + + bytes([0x00, 0x07, multiple, ] + [0x00] * 11)) msg_handler = handler.StandardCmd(msg, self.handle_set_flags) seq.add_msg(msg, msg_handler) diff --git a/insteon_mqtt/mqtt/IOLinc.py b/insteon_mqtt/mqtt/IOLinc.py index 2a6dbe26..c3e70171 100644 --- a/insteon_mqtt/mqtt/IOLinc.py +++ b/insteon_mqtt/mqtt/IOLinc.py @@ -30,8 +30,8 @@ def __init__(self, mqtt, device): # Output state change reporting template. self.msg_state = MsgTemplate( topic='insteon/{{address}}/state', - payload='{ "sensor" : "{{sensor_on_str.lower()}}"",' + - ' relay" : {{relay_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( 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/mqtt/test_IOLinc.py b/tests/mqtt/test_IOLinc.py index f1cf45ee..6fc05bd4 100644 --- a/tests/mqtt/test_IOLinc.py +++ b/tests/mqtt/test_IOLinc.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_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_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)): From 850468ae0fb5603c4d7f7f10dfc35866f0d68b54 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Thu, 5 Mar 2020 18:03:29 -0800 Subject: [PATCH 08/30] IOLinc Unit Tests; Fix Bugs Caught by Unit Tests I have really never done unit tests before this project. I am starting to get a better idea of the tools available. I believe these are more thorough and less duplicate code than my prior work. However, still never sure if I am "fully" testing the appropriate aspects. --- insteon_mqtt/device/IOLinc.py | 25 +- tests/device/test_IOLinc.py | 394 ++++++++++++++++++ .../{test_IOLinc.py => test_IOLincMqtt.py} | 0 3 files changed, 400 insertions(+), 19 deletions(-) create mode 100644 tests/device/test_IOLinc.py rename tests/mqtt/{test_IOLinc.py => test_IOLincMqtt.py} (100%) diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index 68f62ee1..849f5af5 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -169,7 +169,7 @@ def mode(self, val): if val in IOLinc.Modes: meta = {'mode': val} existing = self.db.get_meta('IOLinc') - if isinstance(existing, dict) and 'mode' in meta: + if isinstance(existing, dict): existing.update(meta) self.db.set_meta('IOLinc', existing) else: @@ -199,7 +199,7 @@ def trigger_reverse(self, val): """ meta = {'trigger_reverse': val} existing = self.db.get_meta('IOLinc') - if isinstance(existing, dict) and 'trigger_reverse' in meta: + if isinstance(existing, dict): existing.update(meta) self.db.set_meta('IOLinc', existing) else: @@ -226,7 +226,7 @@ def relay_linked(self, val): """ meta = {'relay_linked': val} existing = self.db.get_meta('IOLinc') - if isinstance(existing, dict) and 'relay_linked' in meta: + if isinstance(existing, dict): existing.update(meta) self.db.set_meta('IOLinc', existing) else: @@ -253,7 +253,7 @@ def momentary_secs(self, val): """ meta = {'momentary_secs': val} existing = self.db.get_meta('IOLinc') - if isinstance(existing, dict) and 'momentary_secs' in meta: + if isinstance(existing, dict): existing.update(meta) self.db.set_meta('IOLinc', existing) else: @@ -367,7 +367,7 @@ def set_flags(self, on_done, **kwargs): "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) seq = CommandSeq(self.protocol, "Device flags set", on_done) @@ -519,18 +519,6 @@ def refresh(self, force=False, on_done=None): # Run all the commands. seq.run() - #----------------------------------------------------------------------- - def sensor_is_on(self): - """Return if the device sensor is on or not. - """ - return self._sensor_is_on - - #----------------------------------------------------------------------- - def relay_is_on(self): - """Return if the device relay is on or not. - """ - return self._relay_is_on - #----------------------------------------------------------------------- def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", on_done=None): @@ -975,7 +963,6 @@ def _set_relay_is_on(self, is_on, reason="", momentary=False): self.label) self._momentary_call = None - #----------------------------------------------------------------------- #----------------------------------------------------------------------- def link_data_to_pretty(self, is_controller, data): """Converts Link Data1-3 to Human Readable Attributes @@ -1026,7 +1013,7 @@ def link_data_from_pretty(self, is_controller, data): data_3 = data['data_3'] if not is_controller: if 'on_off' in data: - data_1 = 0xFF if data[on_off] else 0x00 + data_1 = 0xFF if data['on_off'] else 0x00 return [data_1, data_2, data_3] #----------------------------------------------------------------------- diff --git a/tests/device/test_IOLinc.py b/tests/device/test_IOLinc.py new file mode 100644 index 00000000..d7644bfe --- /dev/null +++ b/tests/device/test_IOLinc.py @@ -0,0 +1,394 @@ +#=========================================================================== +# +# 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, mock): + 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, mock): + 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, mock): + 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_bad_mode(self, test_iolinc): + test_iolinc.mode = 'bad mode' + assert test_iolinc.mode == IM.device.IOLinc.Modes.LATCHING + + def test_set_flags_empty(self, test_iolinc, mock): + 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, mock): + with pytest.raises(Exception): + test_iolinc.trigger_reverse = 0 + 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]), + ]) + def test_set_flags_mode(self, test_iolinc, mock, mode, expected): + self.mode = IM.device.IOLinc.Modes.LATCHING + 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, mock, flag, expected): + test_iolinc.momentary_secs = 0 + test_iolinc.relay_linked = 0 + test_iolinc.trigger_reverse = 0 + 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, mock, level, expected): + 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, mock, is_on, expected): + 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, mock, is_on, mode, moment, relay, + add, remove): + mock.patch.object(IM.Signal, 'emit') + mock.patch.object(test_iolinc.modem.timed_call, 'add') + 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, mock, linked, cmd1, sensor, + relay): + 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, mock, cmd2, expected): + 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, mock, cmd2, expected): + 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, mock, cmd1, type, expected): + 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, mock, 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 + 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/mqtt/test_IOLinc.py b/tests/mqtt/test_IOLincMqtt.py similarity index 100% rename from tests/mqtt/test_IOLinc.py rename to tests/mqtt/test_IOLincMqtt.py From ff576862328ff895d8786cb88b3a92f87b3ee112 Mon Sep 17 00:00:00 2001 From: Kevin Keegan Date: Wed, 29 Apr 2020 11:40:27 -0700 Subject: [PATCH 09/30] Fix Bug in How Mode Enumeration is Stored in Data --- insteon_mqtt/device/IOLinc.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index 849f5af5..837312c1 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -155,7 +155,11 @@ def mode(self): meta = self.db.get_meta('IOLinc') ret = IOLinc.Modes.LATCHING if isinstance(meta, dict) and 'mode' in meta: - ret = meta['mode'] + try: + ret = IOLinc.Modes(meta['mode']) + except ValueError: + # Somehow we saved a value that doesn't exist + pass return ret #----------------------------------------------------------------------- @@ -167,7 +171,7 @@ def mode(self, val): val: (IOLinc.Modes) """ if val in IOLinc.Modes: - meta = {'mode': val} + meta = {'mode': IOLinc.Modes[val].value} existing = self.db.get_meta('IOLinc') if isinstance(existing, dict): existing.update(meta) From 292db9da15193375b7f8a682c07357f24b5f377c Mon Sep 17 00:00:00 2001 From: "Kevin P. Fleming" Date: Tue, 17 Nov 2020 08:00:31 -0500 Subject: [PATCH 10/30] Detect disconnections during poll() calls The Paho-MQTT client may not invoke the 'on_disconnect' callback when used with an external loop, so check for connection loss during calls to loop_misc() and handle it appropriately. Closes #222. --- insteon_mqtt/network/Mqtt.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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): From 0cc32e2a2d51fca57765cab5f8e8554f6fdc3abd Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 25 Nov 2020 15:48:14 -0800 Subject: [PATCH 11/30] Increase Retry Count on DBGet and DBModify Not sure why these are set to 0 or unset which defaults to 0. For i2 devices there is not a concern about getting confused as all messages have the address in them. --- insteon_mqtt/handler/DeviceDbGet.py | 2 +- insteon_mqtt/handler/DeviceDbModify.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/insteon_mqtt/handler/DeviceDbGet.py b/insteon_mqtt/handler/DeviceDbGet.py index 0d4cad2f..20066380 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): """Constructor The on_done callback has the signature on_done(success, msg, entry) diff --git a/insteon_mqtt/handler/DeviceDbModify.py b/insteon_mqtt/handler/DeviceDbModify.py index 73c632a1..40f95ba5 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: From 9f11198a6c3474929b7be9c68829b53505faa7ee Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 25 Nov 2020 15:52:37 -0800 Subject: [PATCH 12/30] Carry Through Retry to Super --- insteon_mqtt/handler/DeviceDbModify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/insteon_mqtt/handler/DeviceDbModify.py b/insteon_mqtt/handler/DeviceDbModify.py index 40f95ba5..c98526a7 100644 --- a/insteon_mqtt/handler/DeviceDbModify.py +++ b/insteon_mqtt/handler/DeviceDbModify.py @@ -29,7 +29,7 @@ def __init__(self, device_db, entry, on_done=None, num_retry=3): 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 From dc0f64a082c6247788a4d0f1ce1e5e03e1f3b17b Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Thu, 3 Dec 2020 12:09:29 -0800 Subject: [PATCH 13/30] No Retry on Device DB Get But Increase Timeout If the dumping of the database times out halfway through, there is nothing we can do but wait. Resending the request half way through doesn't cause the device to do anything and only risks talking over the device messages. Instead, since this routine is somewhat delicate, we give it double the time_out to allow any messages to arrive. --- insteon_mqtt/handler/DeviceDbGet.py | 14 ++++++++++++-- insteon_mqtt/handler/DeviceRefresh.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/insteon_mqtt/handler/DeviceDbGet.py b/insteon_mqtt/handler/DeviceDbGet.py index 20066380..f1a0912c 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=3): + def __init__(self, device_db, on_done, num_retry=0, time_out=10): """Constructor The on_done callback has the signature on_done(success, msg, entry) @@ -38,8 +38,18 @@ def __init__(self, device_db, on_done, num_retry=3): 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 should be 0 for this handler. This is because the + only message sent out is the initial request for a dump of + the database. If the handler times out, there is no way + to recover, besides starting the request over again. + time_out (int): Timeout in seconds. The default for this handler + is double the default rate. This is because the + communication is almost 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. """ - super().__init__(on_done, num_retry) + super().__init__(on_done, num_retry, time_out) self.db = device_db #----------------------------------------------------------------------- diff --git a/insteon_mqtt/handler/DeviceRefresh.py b/insteon_mqtt/handler/DeviceRefresh.py index 869f7c76..8d90897e 100644 --- a/insteon_mqtt/handler/DeviceRefresh.py +++ b/insteon_mqtt/handler/DeviceRefresh.py @@ -127,7 +127,7 @@ def on_done(success, message, data): db_msg = Msg.OutExtended.direct(self.addr, 0x2f, 0x00, bytes(14)) msg_handler = DeviceDbGet(self.device.db, on_done, - num_retry=3) + num_retry=0) self.device.send(db_msg, msg_handler) # Either way - this transaction is complete. From 3a6ce704063fcdee1acd1dc2224eb14db5b5d233 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 14:07:27 -0800 Subject: [PATCH 14/30] Use Device.Send in CommandSeq; Add Modem.Send Everything should send through Device.Send for two reasons: 1.In order to properly calculate outgoing hop counts. (This change may fix some latent errors we had not noticed.) 2. In order to enable caching outgoing messages for battery devices Add a send() function to the Modem device for consistencey with devices. --- insteon_mqtt/CommandSeq.py | 15 ++++++------- insteon_mqtt/Modem.py | 32 ++++++++++++++++++++++------ insteon_mqtt/device/Base.py | 12 +++++------ insteon_mqtt/device/BatterySensor.py | 2 +- insteon_mqtt/device/Dimmer.py | 4 ++-- insteon_mqtt/device/FanLinc.py | 4 ++-- insteon_mqtt/device/IOLinc.py | 4 ++-- insteon_mqtt/device/KeypadLinc.py | 8 +++---- insteon_mqtt/device/Leak.py | 2 +- insteon_mqtt/device/Motion.py | 2 +- insteon_mqtt/device/Outlet.py | 6 +++--- insteon_mqtt/device/Remote.py | 2 +- insteon_mqtt/device/SmokeBridge.py | 4 ++-- insteon_mqtt/device/Switch.py | 4 ++-- insteon_mqtt/device/Thermostat.py | 4 ++-- 15 files changed, 62 insertions(+), 43 deletions(-) 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..5a581314 100644 --- a/insteon_mqtt/Modem.py +++ b/insteon_mqtt/Modem.py @@ -336,7 +336,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 +371,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. @@ -610,6 +610,26 @@ def factory_reset(self, on_done=None): msg_handler = handler.ModemReset(self, on_done) self.protocol.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) + #----------------------------------------------------------------------- def sync(self, dry_run=True, refresh=True, sequence=None, on_done=None): """Syncs the links on the device. @@ -647,7 +667,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: @@ -712,7 +732,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. @@ -1142,7 +1162,7 @@ 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. @@ -1200,7 +1220,7 @@ 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 = CommandSeq(self, "Delete complete", on_done) seq.add(self.db.delete_on_device, self.protocol, entry) # For two way commands, insert a callback so that when the modem diff --git a/insteon_mqtt/device/Base.py b/insteon_mqtt/device/Base.py index 0c92b3dd..e93e7df8 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: @@ -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 @@ -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 diff --git a/insteon_mqtt/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index c01f58bd..87953da3 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -100,7 +100,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. 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..deade32d 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -189,7 +189,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. @@ -337,7 +337,7 @@ 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 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..0ce67e28 100644 --- a/insteon_mqtt/device/Leak.py +++ b/insteon_mqtt/device/Leak.py @@ -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. diff --git a/insteon_mqtt/device/Motion.py b/insteon_mqtt/device/Motion.py index beeb1fc8..6da29370 100644 --- a/insteon_mqtt/device/Motion.py +++ b/insteon_mqtt/device/Motion.py @@ -141,7 +141,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..2a6151e9 100644 --- a/insteon_mqtt/device/Remote.py +++ b/insteon_mqtt/device/Remote.py @@ -89,7 +89,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. 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 From 9819782bb29ccc1fcafd1f6e610b4a643d02ee26 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 15:24:40 -0800 Subject: [PATCH 15/30] Remove Apply_Diff as it is Not Used This was a proposed function for use by the Scene Sync feature, we ended up using a different function. --- insteon_mqtt/db/Device.py | 20 -------------------- insteon_mqtt/db/Modem.py | 18 ------------------ 2 files changed, 38 deletions(-) diff --git a/insteon_mqtt/db/Device.py b/insteon_mqtt/db/Device.py index 445a5c0f..e118f3b2 100644 --- a/insteon_mqtt/db/Device.py +++ b/insteon_mqtt/db/Device.py @@ -606,26 +606,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. diff --git a/insteon_mqtt/db/Modem.py b/insteon_mqtt/db/Modem.py index 40eaccd2..dea57617 100644 --- a/insteon_mqtt/db/Modem.py +++ b/insteon_mqtt/db/Modem.py @@ -485,24 +485,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. From 891e66abc0ea1c2923b2cb656e0aee65130d21d0 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 15:29:28 -0800 Subject: [PATCH 16/30] Clean Up Args in Add and Delete on Device No need to pass protocol or device as the database object already has the device object --- insteon_mqtt/Modem.py | 8 ++++---- insteon_mqtt/db/Device.py | 31 +++++++++++++------------------ insteon_mqtt/db/Modem.py | 12 ++++-------- insteon_mqtt/device/Base.py | 8 ++++---- tests/db/test_Device.py | 6 ++---- 5 files changed, 27 insertions(+), 38 deletions(-) diff --git a/insteon_mqtt/Modem.py b/insteon_mqtt/Modem.py index 5a581314..185cb9e3 100644 --- a/insteon_mqtt/Modem.py +++ b/insteon_mqtt/Modem.py @@ -702,7 +702,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 @@ -714,7 +714,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): @@ -1168,7 +1168,7 @@ def _db_update(self, local_group, is_controller, remote_addr, remote_group, # 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. @@ -1221,7 +1221,7 @@ def _db_delete(self, addr, group, is_controller, two_way, refresh, # Add the function delete call to the sequence. seq = CommandSeq(self, "Delete complete", on_done) - seq.add(self.db.delete_on_device, self.protocol, 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/db/Device.py b/insteon_mqtt/db/Device.py index e118f3b2..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): @@ -741,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. @@ -758,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() @@ -770,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. @@ -789,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. @@ -800,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) @@ -817,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 dea57617..a1589894 100644 --- a/insteon_mqtt/db/Modem.py +++ b/insteon_mqtt/db/Modem.py @@ -268,7 +268,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 +283,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 +316,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 +334,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 +400,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): diff --git a/insteon_mqtt/device/Base.py b/insteon_mqtt/device/Base.py index e93e7df8..cae31418 100644 --- a/insteon_mqtt/device/Base.py +++ b/insteon_mqtt/device/Base.py @@ -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) @@ -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 @@ -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/tests/db/test_Device.py b/tests/db/test_Device.py index b3780f40..fc8d663b 100644 --- a/tests/db/test_Device.py +++ b/tests/db/test_Device.py @@ -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] From 13df2e8707a0f8e553f1e90d825e7ed4b569fd37 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 15:30:36 -0800 Subject: [PATCH 17/30] Funnel All Send Calls Through Device Object This way device can properly set hop counts and the device can queue messages for battery devices. --- insteon_mqtt/Modem.py | 8 ++++---- insteon_mqtt/db/Modem.py | 1 - insteon_mqtt/handler/Base.py | 2 ++ insteon_mqtt/handler/ModemDbGet.py | 2 +- insteon_mqtt/handler/ModemDbModify.py | 2 +- tests/db/test_Device.py | 2 +- tests/handler/test_ModemDbGet.py | 15 +++++++++++++-- 7 files changed, 22 insertions(+), 10 deletions(-) diff --git a/insteon_mqtt/Modem.py b/insteon_mqtt/Modem.py index 185cb9e3..8d3af850 100644 --- a/insteon_mqtt/Modem.py +++ b/insteon_mqtt/Modem.py @@ -192,7 +192,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): @@ -608,7 +608,7 @@ 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.protocol.send(msg, msg_handler) + self.send(msg, msg_handler) #----------------------------------------------------------------------- def send(self, msg, msg_handler, high_priority=False, after=None): @@ -857,7 +857,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): @@ -984,7 +984,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): diff --git a/insteon_mqtt/db/Modem.py b/insteon_mqtt/db/Modem.py index a1589894..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() 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/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/tests/db/test_Device.py b/tests/db/test_Device.py index fc8d663b..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]) 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)) From d74402b004d008e5c4dd744daecdbe7e60fae96b Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 16:03:01 -0800 Subject: [PATCH 18/30] Queue Battery Device Messages; Add Protocol Write Queue Search Queue messages to battery devices. Start sending them when the device sends a broadcast. Add a search function to protocol so that we only try and send one message at a time to the battery devices. --- insteon_mqtt/Protocol.py | 14 +++++++++ insteon_mqtt/device/BatterySensor.py | 45 ++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/insteon_mqtt/Protocol.py b/insteon_mqtt/Protocol.py index 74cfdf67..fb3cca01 100644 --- a/insteon_mqtt/Protocol.py +++ b/insteon_mqtt/Protocol.py @@ -232,6 +232,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. diff --git a/insteon_mqtt/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index 87953da3..221ffc28 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -78,6 +78,34 @@ def __init__(self, protocol, modem, address, name=None): } self._is_on = False + self._send_queue = [] + + #----------------------------------------------------------------------- + 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. + """ + self._send_queue.append([msg, msg_handler, high_priority, after]) #----------------------------------------------------------------------- def pair(self, on_done=None): @@ -146,6 +174,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. """ @@ -172,12 +203,14 @@ 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) + # 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() + self.protocol.send(*args) #----------------------------------------------------------------------- def handle_on_off(self, msg): From 5789dce2509903de1d5be92030101f6f98f09f8e Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 17:36:13 -0800 Subject: [PATCH 19/30] DeviceRefresh Should not Handle Broadcast Messages Need to check response is an ACK or NAK otherwise the handler may unintentionnaly handle a broadcast command with bad results. --- insteon_mqtt/handler/DeviceRefresh.py | 111 ++++++++++++++------------ 1 file changed, 58 insertions(+), 53 deletions(-) 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 From b654f18f611b6035dfae13d5d2f974921a175970 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 17:41:15 -0800 Subject: [PATCH 20/30] Queue Battery Device Sends; Pop On Msg Received Queue for when the device is awake, which is assume to be right after it sends a message. --- insteon_mqtt/device/BatterySensor.py | 33 +++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/insteon_mqtt/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index 221ffc28..141b74a2 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -105,6 +105,7 @@ def send(self, msg, msg_handler, high_priority=False, after=None): is not guaranteed - the message will be send no earlier than this. """ + LOG.info("BatterySensor %s - queueing msg until awake", self.label) self._send_queue.append([msg, msg_handler, high_priority, after]) #----------------------------------------------------------------------- @@ -203,14 +204,8 @@ def handle_broadcast(self, msg): # (without sending anything out). super().handle_broadcast(msg) - # 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() - self.protocol.send(*args) + # Pop messages from _send_queue if necessary + self._pop_send_queue() #----------------------------------------------------------------------- def handle_on_off(self, msg): @@ -285,3 +280,25 @@ 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() + orig_on_done = args[1].on_done + # Inject a callback to this function in every handler + def on_done(success, message, data): + if success: + self._pop_send_queue() + # now call original on_done + orig_on_done(success, message, data) + args[1].on_done = on_done + self.protocol.send(*args) + + #----------------------------------------------------------------------- From ccccd918fa3916eb050e28a630d39d133ee99683 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Wed, 9 Dec 2020 18:11:47 -0800 Subject: [PATCH 21/30] Add Awake Feature and Documentation --- docs/mqtt.md | 22 ++++++++++++++++ insteon_mqtt/cmd_line/device.py | 10 +++++++ insteon_mqtt/cmd_line/main.py | 11 ++++++++ insteon_mqtt/device/BatterySensor.py | 39 ++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/docs/mqtt.md b/docs/mqtt.md index 8be6f0b2..fd85e94a 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -534,6 +534,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 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/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index 141b74a2..5644da46 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -3,6 +3,7 @@ # Insteon battery powered motion sensor # #=========================================================================== +import time from .Base import Base from ..CommandSeq import CommandSeq from .. import log @@ -79,6 +80,10 @@ 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): @@ -105,8 +110,13 @@ def send(self, msg, msg_handler, high_priority=False, after=None): is not guaranteed - the message will be send no earlier than this. """ - LOG.info("BatterySensor %s - queueing msg until awake", self.label) - self._send_queue.append([msg, msg_handler, high_priority, after]) + # It seems like pressing the set button seems to keep them awake for + # about 3 minutes + if self._awake_time >= (time.time() - 180): + self.protocol.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): @@ -265,6 +275,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 to Protocol + for args in self._send_queue: + self.protocol.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. From 1dcb92152bebfc66f23dc36fb7083cff73fbdd12 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Thu, 10 Dec 2020 09:32:46 -0800 Subject: [PATCH 22/30] Allow Retry and Regular Timeout to Initial Get DB Request Remove retries and increase the timeout once there is no longer anything to be sent from the modem end. --- insteon_mqtt/handler/DeviceDbGet.py | 29 +++++++++++++++++---------- insteon_mqtt/handler/DeviceRefresh.py | 2 +- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/insteon_mqtt/handler/DeviceDbGet.py b/insteon_mqtt/handler/DeviceDbGet.py index f1a0912c..76fb47f5 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, time_out=10): + 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,16 +38,19 @@ def __init__(self, device_db, on_done, num_retry=0, time_out=10): 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 should be 0 for this handler. This is because the - only message sent out is the initial request for a dump of - the database. If the handler times out, there is no way - to recover, besides starting the request over again. - time_out (int): Timeout in seconds. The default for this handler - is double the default rate. This is because the - communication is almost 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. + 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, time_out) self.db = device_db @@ -93,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/DeviceRefresh.py b/insteon_mqtt/handler/DeviceRefresh.py index 8d90897e..869f7c76 100644 --- a/insteon_mqtt/handler/DeviceRefresh.py +++ b/insteon_mqtt/handler/DeviceRefresh.py @@ -127,7 +127,7 @@ def on_done(success, message, data): db_msg = Msg.OutExtended.direct(self.addr, 0x2f, 0x00, bytes(14)) msg_handler = DeviceDbGet(self.device.db, on_done, - num_retry=0) + num_retry=3) self.device.send(db_msg, msg_handler) # Either way - this transaction is complete. From b23a73b2a0692cd61649e63f1c47cbfc604b964c Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Thu, 10 Dec 2020 11:49:10 -0800 Subject: [PATCH 23/30] Sending Through Device.Base not Protocol so Hops are Added --- insteon_mqtt/device/BatterySensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/insteon_mqtt/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index 5644da46..f94ebb01 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -113,7 +113,7 @@ def send(self, msg, msg_handler, high_priority=False, after=None): # It seems like pressing the set button seems to keep them awake for # about 3 minutes if self._awake_time >= (time.time() - 180): - self.protocol.send(msg, msg_handler, high_priority, after) + 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]) @@ -293,9 +293,9 @@ def awake(self, on_done): # Update the awake time to be now self._awake_time = time.time() - # Dump all messages in the queue to Protocol + # Dump all messages in the queue for args in self._send_queue: - self.protocol.send(*args) + super().send(*args) #Empty the queue self._send_queue = [] on_done(True, "Complete", None) @@ -334,6 +334,6 @@ def on_done(success, message, data): # now call original on_done orig_on_done(success, message, data) args[1].on_done = on_done - self.protocol.send(*args) + super().send(*args) #----------------------------------------------------------------------- From 220d2b64d72a06efafe0b964a4718bbcda806dd8 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Thu, 10 Dec 2020 13:37:44 -0800 Subject: [PATCH 24/30] Reorganize Leak, Motion, Remote to Inherit from BatterySensor This is necessary to enable the awake functionality. Generally just required minor changes the handle_broadcast to accomidate this. --- insteon_mqtt/device/BatterySensor.py | 9 +++-- insteon_mqtt/device/Leak.py | 56 ++-------------------------- insteon_mqtt/device/Motion.py | 2 + insteon_mqtt/device/Remote.py | 46 +++++++---------------- 4 files changed, 25 insertions(+), 88 deletions(-) diff --git a/insteon_mqtt/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index f94ebb01..2eba2e19 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -7,6 +7,7 @@ from .Base import Base from ..CommandSeq import CommandSeq from .. import log +from .. import on_off from ..Signal import Signal LOG = log.get_logger() @@ -18,7 +19,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 @@ -196,8 +198,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) diff --git a/insteon_mqtt/device/Leak.py b/insteon_mqtt/device/Leak.py index 0ce67e28..ce2e84f7 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 @@ -120,56 +122,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 6da29370..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 diff --git a/insteon_mqtt/device/Remote.py b/insteon_mqtt/device/Remote.py index 2a6151e9..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() @@ -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. From 06dfb7e3d5274e0ad0650553ce9a44186dea4427 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Thu, 10 Dec 2020 14:46:23 -0800 Subject: [PATCH 25/30] Add Msg_Finished Signal; Battery Dequeue Send_Queue on Signal Adds a signal to Protocol that emits on a write_msg Msg.FINISHED after the message has been removed from the _write_queue Battery device listens to Msg_Finished signal and dequeues a msg from _send_queue if necessary. This is cleaner than injecting into the on_done and actually works. The on_done method doesn't work correctly with handlers and sequences that do not call on_done until a series of messages have been transmitted. --- insteon_mqtt/Protocol.py | 7 ++++++ insteon_mqtt/device/BatterySensor.py | 36 +++++++++++++++++++++------- tests/util/helpers/main.py | 1 + 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/insteon_mqtt/Protocol.py b/insteon_mqtt/Protocol.py index fb3cca01..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() @@ -435,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/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index 2eba2e19..4ba44fae 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -8,6 +8,7 @@ from ..CommandSeq import CommandSeq from .. import log from .. import on_off +from .. import message as Msg from ..Signal import Signal LOG = log.get_logger() @@ -69,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 = { @@ -170,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. @@ -329,14 +357,6 @@ def _pop_send_queue(self): not self.protocol.is_addr_in_write_queue(self.addr)): LOG.info("BatterySensor %s awake - sending msg", self.label) args = self._send_queue.pop() - orig_on_done = args[1].on_done - # Inject a callback to this function in every handler - def on_done(success, message, data): - if success: - self._pop_send_queue() - # now call original on_done - orig_on_done(success, message, data) - args[1].on_done = on_done super().send(*args) #----------------------------------------------------------------------- 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): From d03a115aa20e61d400401f570d1705902e936e67 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Thu, 10 Dec 2020 15:54:25 -0800 Subject: [PATCH 26/30] Fix IOLinc PYtests; Fix Some Enum Errors in IOLinc This is weird this has been working fine, and certainly passed the tests previously. --- insteon_mqtt/device/IOLinc.py | 7 +- tests/device/test_IOLinc.py | 339 +++++++++++++++++----------------- 2 files changed, 173 insertions(+), 173 deletions(-) diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index 837312c1..ddd820ea 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -171,7 +171,7 @@ def mode(self, val): val: (IOLinc.Modes) """ if val in IOLinc.Modes: - meta = {'mode': IOLinc.Modes[val].value} + meta = {'mode': val.value} existing = self.db.get_meta('IOLinc') if isinstance(existing, dict): existing.update(meta) @@ -379,7 +379,10 @@ def set_flags(self, on_done, **kwargs): # Loop through flags, sending appropriate command for each flag for flag in kwargs: if flag == 'mode': - mode = IOLinc.Modes[kwargs[flag].upper()] + 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: diff --git a/tests/device/test_IOLinc.py b/tests/device/test_IOLinc.py index d7644bfe..d0d15fc1 100644 --- a/tests/device/test_IOLinc.py +++ b/tests/device/test_IOLinc.py @@ -28,73 +28,70 @@ def test_iolinc(tmpdir): class Test_IOLinc_Simple(): - def test_pair(self, test_iolinc, mock): - 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, mock): - 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, mock): - 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 + 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_bad_mode(self, test_iolinc): - test_iolinc.mode = 'bad mode' - assert test_iolinc.mode == IM.device.IOLinc.Modes.LATCHING - - def test_set_flags_empty(self, test_iolinc, mock): - mock.patch.object(IM.CommandSeq, 'add_msg') - test_iolinc.set_flags(None) - assert IM.CommandSeq.add_msg.call_count == 0 + 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, mock): + def test_set_flags_unknown(self, test_iolinc): with pytest.raises(Exception): test_iolinc.trigger_reverse = 0 - mock.patch.object(IM.CommandSeq, 'add_msg') - test_iolinc.set_flags(None, Unknown=1) - assert IM.CommandSeq.add_msg.call_count == 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, mock, mode, expected): + def test_set_flags_mode(self, test_iolinc, mode, expected): self.mode = IM.device.IOLinc.Modes.LATCHING - 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 + 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]), @@ -107,25 +104,25 @@ def test_set_flags_mode(self, test_iolinc, mock, mode, expected): ({"momentary_secs": 3000}, [0x2e, 0x00, 0x96, 0xc8]), ({"momentary_secs": 6300}, [0x2e, 0x00, 0xfc, 0xfa]), ]) - def test_set_flags_other(self, test_iolinc, mock, flag, expected): + 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 - 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 + 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(): @@ -134,23 +131,23 @@ class Test_IOLinc_Set(): (0x01, 0x11), (0xff, 0x11), ]) - def test_set(self, test_iolinc, mock, level, expected): - 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 + 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, mock, is_on, expected): - 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 + 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), @@ -160,20 +157,20 @@ def test_sensor_on(self, test_iolinc, mock, is_on, expected): (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, mock, is_on, mode, moment, relay, + def test_relay_on(self, test_iolinc, is_on, mode, moment, relay, add, remove): - mock.patch.object(IM.Signal, 'emit') - mock.patch.object(test_iolinc.modem.timed_call, 'add') - 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 + 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", [ @@ -183,25 +180,25 @@ class Test_Handles(): (True, 0x13, False, False), (False, 0x06, None, None), ]) - def test_handle_broadcast(self, test_iolinc, mock, linked, cmd1, sensor, + def test_handle_broadcast(self, test_iolinc, linked, cmd1, sensor, relay): - 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 + 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), @@ -252,50 +249,50 @@ def test_handle_set_flags(self, test_iolinc): (0x00, False), (0Xff, True), ]) - def test_handle_refresh_relay(self, test_iolinc, mock, cmd2, expected): - 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 + 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, mock, cmd2, expected): - 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 + 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, mock, cmd1, type, expected): - 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 + 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), @@ -315,36 +312,36 @@ def test_handle_ack(self, test_iolinc, mock, cmd1, type, expected): (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, mock, cmd1, entry_d1, mode, + 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 - 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 + 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: From 0a7605812525f796665c273b3e2fafcb16eb3302 Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Fri, 11 Dec 2020 15:34:53 -0800 Subject: [PATCH 27/30] Make mqtt.BatterySensor Parent of Leak and Remote Inherit mqtt code just like we did for device code. Promote the heartbeat topic to the BatterySensor class, I think there are other battery devices that use heartbeats. Plus we had halfway already included it in the BaseSensor code. Left the ability to override topics and value templates in the Leak and Remote just like in the Motion. I think that this could be helpful for someone in the future. --- config.yaml | 80 ++++++++++++++++-------------- docs/mqtt.md | 18 ++++++- insteon_mqtt/device/Leak.py | 2 - insteon_mqtt/mqtt/BatterySensor.py | 31 +++++++++++- insteon_mqtt/mqtt/Leak.py | 67 +++++-------------------- insteon_mqtt/mqtt/Remote.py | 44 ++++++---------- tests/mqtt/test_Leak.py | 6 +-- tests/mqtt/test_Remote.py | 11 ++-- 8 files changed, 125 insertions(+), 134 deletions(-) diff --git a/config.yaml b/config.yaml index 543b170f..ead8b4a6 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 #------------------------------------------------------------------------ diff --git a/docs/mqtt.md b/docs/mqtt.md index fd85e94a..b4e8005f 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -928,6 +928,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: ``` @@ -939,6 +951,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}}' ``` --- @@ -990,8 +1006,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}}' ``` --- diff --git a/insteon_mqtt/device/Leak.py b/insteon_mqtt/device/Leak.py index ce2e84f7..9a3f3e46 100644 --- a/insteon_mqtt/device/Leak.py +++ b/insteon_mqtt/device/Leak.py @@ -59,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 = { 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/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/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_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 From ec15403f59c11b49f8db89293bffd68364fb9c6f Mon Sep 17 00:00:00 2001 From: KRKeegan Date: Fri, 11 Dec 2020 16:19:22 -0800 Subject: [PATCH 28/30] Bump 0.7.2 -> 0.7.3 --- .bumpversion.cfg | 2 +- HISTORY.md | 20 +++++++++++++++++++- README.md | 2 +- hassio/config.json | 2 +- insteon_mqtt/__init__.py | 2 +- setup.py | 2 +- 6 files changed, 24 insertions(+), 6 deletions(-) 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..b73d121a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,9 @@ ## [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 +12,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 +397,7 @@ [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 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/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/__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/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", From a3f61b89f5cd032d0a8249b4ce51a0e58fd34d38 Mon Sep 17 00:00:00 2001 From: Kevin Robert Keegan Date: Sat, 12 Dec 2020 12:34:51 -0800 Subject: [PATCH 29/30] Update History --- HISTORY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index b73d121a..4ca3d0e8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,23 @@ # 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 + +### Fixes + ## [0.7.3] Fixing a number of small bugs in preparation for upcoming releases which @@ -401,3 +419,4 @@ will add new features. [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 From 2c6e27efdc80355b75242f4e5046a3ff163be9c1 Mon Sep 17 00:00:00 2001 From: Kevin Robert Keegan Date: Sat, 12 Dec 2020 12:56:37 -0800 Subject: [PATCH 30/30] Update History --- HISTORY.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index 4ca3d0e8..94e80f99 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -16,6 +16,14 @@ - [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] @@ -420,3 +428,4 @@ will add new features. [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