diff --git a/HISTORY.md b/HISTORY.md index 5e1582f6..1f8adf63 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -16,6 +16,10 @@ switches to be used in automation triggers (thanks @NickWaterton). ([Issue #66][I66]). +- Added support for manual mode state reporting (holding buttons down). + Supported by dimmer, keypadlinc, remote, and switch (thanks + @NickWaterton). ([Issue #104][I104]). + - New command 'get_model' added to the command line tool to retrieve and save the Insteon device cat, sub_cat, and firmware revision (thanks @krkeegan). ([Issue #55][I55]). @@ -218,3 +222,4 @@ [I88]: https://github.com/TD22057/insteon-mqtt/issues/88 [I95]: https://github.com/TD22057/insteon-mqtt/issues/95 [I97]: https://github.com/TD22057/insteon-mqtt/issues/97 +[I104]: https://github.com/TD22057/insteon-mqtt/issues/104 diff --git a/config.yaml b/config.yaml index d4e757ae..5327bdd0 100644 --- a/config.yaml +++ b/config.yaml @@ -200,6 +200,15 @@ mqtt: state_topic: 'insteon/{{address}}/state' 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), 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()}}' + # Input on/off command. Similar functionality to the cmd_topic # but only for turning the device on and off. The output of # passing the payload through the template must match the following: @@ -251,6 +260,15 @@ mqtt: state_payload: > { "state" : "{{on_str.upper()}}", "brightness" : {{level_255}} } + # 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), 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()}}' + # Input on/off command. Similar functionality to the cmd_topic # but only for turning the device on and off. The output of # passing the payload through the template must match the following: @@ -468,7 +486,6 @@ mqtt: # - no concept of separate heat and cool set points # - no humidity state # - no status state - thermostat: # Output state change topic and payload. Available variables for # templating in all cases are: @@ -559,6 +576,16 @@ mqtt: 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 #------------------------------------------------------------------------ @@ -699,6 +726,15 @@ mqtt: dimmer_state_payload: > { "state" : "{{on_str.upper()}}", "brightness" : {{level_255}} } + # 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), 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()}}' + # Input on/off command. For button 1, this will set the load. For other # buttons, it just set the button LED. The output of passing the payload # through the template must match the following: diff --git a/docs/mqtt.md b/docs/mqtt.md index 92a5e1fe..435a9b52 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -419,6 +419,15 @@ which can be used in the templates: - 'fast' is 1 if the mode is fast, 0 otherwise - 'instant' is 1 if the mode is instant, 0 otherwise +Manual state output is invoked when a button on the device is held down. +Manual mode flags are UP or DOWN (when the on or off button is pressed and +held), and STOP (when the button is released. Manual template variables are +name, address, and: + + - 'manual_str' = 'up'/'off'/'down' + - 'manual' = 1/0/-1 + - 'manual_openhab' = 2/1/0 + Input state change messages have the following variables defined which can be used in the templates: @@ -446,17 +455,20 @@ using upper case ON an OFF payloads. ``` switch: - # Output state change: - state_topic: 'insteon/{{address}}/state' - state_payload: '{{on_str}}' + # Output state change: + state_topic: 'insteon/{{address}}/state' + state_payload: '{{on_str}}' - # Direct change only changes the load: - on_off_topic: 'insteon/{{address}}/set' - on_off_payload: '{ "cmd" : "{{value.lower()}}" }' + manual_state_topic: 'insteon/{{address}}/manual_state' + manual_state_payload: '{{manual_str.upper()}}' - # Scene change simulates clicking the switch: - scene_topic: 'insteon/{{address}}/scene' - scene_payload: '{ "cmd" : "{{value.lower()}}" }' + # Direct change only changes the load: + on_off_topic: 'insteon/{{address}}/set' + on_off_payload: '{ "cmd" : "{{value.lower()}}" }' + + # Scene change simulates clicking the switch: + scene_topic: 'insteon/{{address}}/scene' + scene_payload: '{ "cmd" : "{{value.lower()}}" }' ``` When the switch changes state a message like `ON` or `OFF` is @@ -484,6 +496,15 @@ which can be used in the templates: - 'fast' is 1 if the mode is fast, 0 otherwise - 'instant' is 1 if the mode is instant, 0 otherwise +Manual state output is invoked when a button on the device is held down. +Manual mode flags are UP or DOWN (when the on or off button is pressed and +held), and STOP (when the button is released. Manual template variables are +name, address, and: + + - 'manual_str' = 'up'/'off'/'down' + - 'manual' = 1/0/-1 + - 'manual_openhab' = 2/1/0 + Input state change messages have the following variables defined which can be used in the templates: @@ -510,24 +531,27 @@ using a JSON format that contains the level using the tag "brightness". ``` - switch: - # Output state change: - state_topic: 'insteon/{{address}}/state' - state_payload: '{ "state" : "{{on_str}}", "brightness" : {{level_255}} }' + dimmer: + # Output state change: + state_topic: 'insteon/{{address}}/state' + state_payload: '{ "state" : "{{on_str}}", "brightness" : {{level_255}} }' - # Input state change for the load: - on_off_topic: 'insteon/{{address}}/set' - on_off_payload: '{ "cmd" : "{{json.state}}" }' + manual_state_topic: 'insteon/{{address}}/manual_state' + manual_state_payload: '{{manual_str.upper()}}' - # Scene change simulates clicking the switch: - scene_topic: 'insteon/{{address}}/scene' - scene_payload: '{ "cmd" : "{{value.lower()}}" }' + # Input state change for the load: + on_off_topic: 'insteon/{{address}}/set' + on_off_payload: '{ "cmd" : "{{json.state}}" }' + + # Scene change simulates clicking the switch: + scene_topic: 'insteon/{{address}}/scene' + scene_payload: '{ "cmd" : "{{value.lower()}}" }' - # Dimming control: - level_topic: 'insteon/{{address}}/level' - level_payload: > - { "cmd" : "{{json.state}}", - "level" : {{json.brightness}} } + # Dimming control: + level_topic: 'insteon/{{address}}/level' + level_payload: > + { "cmd" : "{{json.state}}", + "level" : {{json.brightness}} } ``` @@ -585,22 +609,22 @@ matching the Home Assistant MQTT fan configuration. ``` fan_linc: - # Output state change: - fan_state_topic: 'insteon/{{address}}/fan/state' - fan_state_payload: '{{on_str}}' + # Output state change: + fan_state_topic: 'insteon/{{address}}/fan/state' + fan_state_payload: '{{on_str}}' - # Input on/off change (payload should be 'ON' or 'OFF') - fan_on_off_topic: 'insteon/{{address}}/fan/set' - fan_on_off_payload: '{ "cmd" : "{{value.lower}}" }' + # Input on/off change (payload should be 'ON' or 'OFF') + fan_on_off_topic: 'insteon/{{address}}/fan/set' + fan_on_off_payload: '{ "cmd" : "{{value.lower}}" }' - # Output speed state change. - fan_speed_topic: 'insteon/{{address}}/fan/speed/state' - fan_speed_payload: '{{level_str}}' + # Output speed state change. + fan_speed_topic: 'insteon/{{address}}/fan/speed/state' + fan_speed_payload: '{{level_str}}' - # Input fan speed change (payload should be 'off', 'low', 'medium', - # or 'high'. - fan_speed_set_topic: 'insteon/{{address}}/fan/speed/set' - fan_speed_set_payload: '{ "cmd" : "{{value.lower}}" }' + # Input fan speed change (payload should be 'off', 'low', 'medium', + # or 'high'. + fan_speed_set_topic: 'insteon/{{address}}/fan/speed/set' + fan_speed_set_payload: '{ "cmd" : "{{value.lower}}" }' ``` @@ -635,32 +659,44 @@ The button change defines the following variables for templates: - 'fast' is 1 if the mode is fast, 0 otherwise - 'instant' is 1 if the mode is instant, 0 otherwise +Manual state output is invoked when a button on the device is held down. +Manual mode flags are UP or DOWN (when the on or off button is pressed and +held), and STOP (when the button is released. Manual template variables are +name, address, and: + + - 'manual_str' = 'up'/'off'/'down' + - 'manual' = 1/0/-1 + - 'manual_openhab' = 2/1/0 + A sample remote control topic and payload configuration is: ``` keypad_linc: - # Output on/off state change: - btn_state_topic: 'insteon/{{address}}/state/{{button}}' - btn_state_payload: '{{on_str.upper()}}' + # Output on/off state change: + btn_state_topic: 'insteon/{{address}}/state/{{button}}' + btn_state_payload: '{{on_str.upper()}}' + + # Output dimmer state changes. + dimmer_state_topic: 'insteon/{{address}}/state/1' + state_payload: '{ "state" : "{{on_str}}", "brightness" : {{level_255}} }' - # Output dimmer state changes. - dimmer_state_topic: 'insteon/{{address}}/state/1' - state_payload: '{ "state" : "{{on_str}}", "brightness" : {{level_255}} }' + manual_state_topic: 'insteon/{{address}}/manual_state/{{button}}' + manual_state_payload: '{{manual_str.upper()}}' - # Input on/off state change. For any button besides 1, this just - # updates the LED state. - btn_on_off_topic: 'insteon/{{address}}/set/{{button}}' - btn_on_off_payload: '{ "cmd" : "{{json.state}}" }' + # Input on/off state change. For any button besides 1, this just + # updates the LED state. + btn_on_off_topic: 'insteon/{{address}}/set/{{button}}' + btn_on_off_payload: '{ "cmd" : "{{json.state}}" }' - # Input dimmer control - level_topic: 'insteon/{{address}}/level/1' - level_payload: > - { "cmd" : "{{json.state}}", - "level" : {{json.brightness}} } + # Input dimmer control + level_topic: 'insteon/{{address}}/level/1' + level_payload: > + { "cmd" : "{{json.state}}", + "level" : {{json.brightness}} } - # Scene input - simulates clicking the button. - btn_scene_topic: 'insteon/{{address}}/scene/{{button}}' - btn_scene_payload: '{ "cmd" : "{{value.lower()}}" }' + # Scene input - simulates clicking the button. + btn_scene_topic: 'insteon/{{address}}/scene/{{button}}' + btn_scene_payload: '{ "cmd" : "{{value.lower()}}" }' ``` --- @@ -772,12 +808,24 @@ The button change defines the following variables for templates: - 'fast' is 1 if the mode is fast, 0 otherwise - 'instant' is 1 if the mode is instant, 0 otherwise +Manual state output is invoked when a button on the device is held down. +Manual mode flags are UP or DOWN (when the on or off button is pressed and +held), and STOP (when the button is released. Manual template variables are +name, address, and: + + - 'manual_str' = 'up'/'off'/'down' + - 'manual' = 1/0/-1 + - 'manual_openhab' = 2/1/0 + A sample remote control topic and payload configuration is: ``` remote: state_topic: 'insteon/{{address}}/state/{{button}}' state_payload: '{{on_str.upper()}}' + + manual_state_topic: 'insteon/{{address}}/manual_state/{{button}}' + manual_state_payload: '{{manual_str.upper()}}' ``` --- diff --git a/insteon_mqtt/Modem.py b/insteon_mqtt/Modem.py index c22ca694..15112021 100644 --- a/insteon_mqtt/Modem.py +++ b/insteon_mqtt/Modem.py @@ -604,7 +604,7 @@ def handle_received(self, msg): device.handle_received(msg) #----------------------------------------------------------------------- - def handle_scene(self, group, cmd): + def handle_scene(self, msg): """Callback for scene simulation commanded messages. This callback is run when we get a reply back from triggering a scene @@ -612,9 +612,11 @@ def handle_scene(self, group, cmd): device will then update the states on the devices in the scene. Args: - group: (int) The group (scene) being ACK'ed. - cmd: (int) The group command (0x11 for on, 0x13 for off). + msg: (InptStandard) Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. """ + group = msg.group + responders = self.db.find_group(group) LOG.debug("Found %s responders in group %s", len(responders), group) LOG.debug("Group %s -> %s", group, [i.addr.hex for i in responders]) @@ -626,7 +628,7 @@ def handle_scene(self, group, cmd): if device: LOG.info("%s broadcast to %s for group %s", self.label, device.addr, group) - device.handle_group_cmd(self.addr, group, cmd) + device.handle_group_cmd(self.addr, msg) else: LOG.warning("%s broadcast - device %s not found", self.label, elem.addr) @@ -674,7 +676,7 @@ def run_command(self, **kwargs): "cmd %s with args: %s", self.addr, cmd, str(kwargs)) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Handle a group command addressed to the modem. This is called when a broadcast message is sent from a device that is diff --git a/insteon_mqtt/device/Base.py b/insteon_mqtt/device/Base.py index 5c3d6671..11505e35 100644 --- a/insteon_mqtt/device/Base.py +++ b/insteon_mqtt/device/Base.py @@ -665,13 +665,13 @@ def handle_broadcast(self, msg): if device: LOG.info("%s broadcast to %s for group %s", self.label, device.addr, group) - device.handle_group_cmd(self.addr, group, msg.cmd1) + device.handle_group_cmd(self.addr, msg) else: LOG.warning("%s broadcast - device %s not found", self.label, elem.addr) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. This is called when this device is a responder to a scene. @@ -681,8 +681,8 @@ def handle_group_cmd(self, addr, group, cmd): Args: addr: (Address) The device that sent the message. This is the controller in the scene. - group: (int) The group being triggered. - cmd: (int) The command byte being sent. + msg: (InptStandard) Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. """ # Default implementation - derived classes should specialize this. LOG.info("Device %s ignoring group cmd - not implemented", self.label) diff --git a/insteon_mqtt/device/Dimmer.py b/insteon_mqtt/device/Dimmer.py index 0a30614b..39cd54ba 100644 --- a/insteon_mqtt/device/Dimmer.py +++ b/insteon_mqtt/device/Dimmer.py @@ -75,6 +75,10 @@ def __init__(self, protocol, modem, address, name=None): # API: func(Device, int level, on_off.Mode mode) self.signal_level_changed = Signal() + # Manual mode start up, down, off + # API: func(Device, on_off.Manual mode) + self.signal_manual = Signal() + # Remote (mqtt) commands mapped to methods calls. Add to the # base class defined commands. self.cmd_map.update({ @@ -402,10 +406,8 @@ def handle_broadcast(self, msg): Args: msg: (InptStandard) Broadcast message from the device. """ - cmd = msg.cmd1 - # ACK of the broadcast - ignore this. - if cmd == 0x06: + if msg.cmd1 == 0x06: LOG.info("Dimmer %s broadcast ACK grp: %s", self.addr, msg.group) if self.broadcast_done: self.broadcast_done() @@ -428,17 +430,16 @@ def handle_broadcast(self, msg): elif not self.broadcast_done: self._set_level(0x00, mode) - # Starting manual increment (cmd2 0x00=up, 0x01=down) - elif cmd == 0x17: - LOG.info("Dimmer %s starting manual change %s", self.addr, - "UP" if msg.cmd2 == 0x00 else "DN") + # Starting or stopping manual increment (cmd2 0x00=up, 0x01=down) + elif on_off.Manual.is_valid(msg.cmd1): + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + LOG.info("Dimmer %s manual change %s", self.addr, manual) - # Stopping manual increment (cmd2 = unused) - elif cmd == 0x18: - LOG.info("Dimmer %s stopping manual change", self.addr) + self.signal_manual.emit(self, manual) # Ping the light to get the new level - self.refresh() + if manual == on_off.Manual.STOP: + self.refresh() # This will find all the devices we're the controller of for # this group and call their handle_group_cmd() methods to @@ -541,7 +542,7 @@ def handle_increment(self, msg, on_done, delta): None) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. This is called when this device is a responder to a scene. The @@ -551,42 +552,43 @@ def handle_group_cmd(self, addr, group, cmd): Args: addr: (Address) The device that sent the message. This is the controller in the scene. - group: (int) The group being triggered. - cmd: (int) The command byte being sent. + msg: (InptStandard) Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. """ # Make sure we're really a responder to this message. This # shouldn't ever occur. - entry = self.db.find(addr, group, is_controller=False) + entry = self.db.find(addr, msg.group, is_controller=False) if not entry: LOG.error("Dimmer %s has no group %s entry from %s", self.addr, - group, addr) + msg.group, addr) return # Handle on/off codes - if on_off.Mode.is_valid(cmd): - is_on, mode = on_off.Mode.decode(cmd) + if on_off.Mode.is_valid(msg.cmd1): + is_on, mode = on_off.Mode.decode(msg.cmd1) level = entry.data[0] if is_on else 0x00 self._set_level(level, mode) # Increment up (32 steps) - elif cmd == 0x15: + elif msg.cmd1 == 0x15: self._set_level(min(0xff, self._level + 8)) # Increment down - elif cmd == 0x16: + elif msg.cmd1 == 0x16: self._set_level(max(0x00, self._level - 8)) - # Starting manual increment (cmd2 0x00=up, 0x01=down) - elif cmd == 0x17: - pass + # Starting/stopping manual increment (cmd2 0x00=up, 0x01=down) + elif on_off.Manual.is_valid(msg.cmd1): + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + self.signal_manual.emit(self, manual) - # Stopping manual increment (cmd2 = unused) - elif cmd == 0x18: # Ping the light to get the new level - self.refresh() + if manual == on_off.Manual.STOP: + self.refresh() else: - LOG.warning("Dimmer %s unknown group cmd %#04x", self.addr, cmd) + LOG.warning("Dimmer %s unknown group cmd %#04x", self.addr, + msg.cmd1) #----------------------------------------------------------------------- def _set_level(self, level, mode=on_off.Mode.NORMAL): diff --git a/insteon_mqtt/device/FanLinc.py b/insteon_mqtt/device/FanLinc.py index e0c3db3b..65bc05ea 100644 --- a/insteon_mqtt/device/FanLinc.py +++ b/insteon_mqtt/device/FanLinc.py @@ -260,7 +260,7 @@ def handle_speed_ack(self, msg, on_done=None): None) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. This is called when this device is a responder to a scene. @@ -270,32 +270,33 @@ def handle_group_cmd(self, addr, group, cmd): Args: addr: (Address) The device that sent the message. This is the controller in the scene. - group: (int) The group being triggered. - cmd: (int) The command byte being sent. + msg: (InptStandard) Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. """ # Group 1 is for the dimmer - pass that to the base class: - if group == 1: - super().handle_group_cmd(addr, group, cmd) + if msg.group == 1: + super().handle_group_cmd(addr, msg) return # Make sure we're really a responder to this message. This # shouldn't ever occur. - entry = self.db.find(addr, group, is_controller=False) + entry = self.db.find(addr, msg.group, is_controller=False) if not entry: LOG.error("FanLinc %s has no group %s entry from %s", self.addr, - group, addr) + msg.group, addr) return # 0x11: on - if cmd == 0x11: + if msg.cmd1 == 0x11: self._set_fan_speed(entry.data[0]) # 0x13: off - elif cmd == 0x13: + elif msg.cmd1 == 0x13: self._set_fan_speed(0x00) else: - LOG.warning("FanLink %s unknown group cmd %#04x", self.addr, cmd) + LOG.warning("FanLink %s unknown group cmd %#04x", self.addr, + msg.cmd1) #----------------------------------------------------------------------- def _set_fan_speed(self, speed_level): diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index 5ba5c065..6d136d48 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -548,7 +548,7 @@ def handle_scene(self, msg, on_done): None) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. This is called when this device is a responder to a scene. @@ -558,21 +558,21 @@ def handle_group_cmd(self, addr, group, cmd): Args: addr: (Address) The device that sent the message. This is the controller in the scene. - group: (int) The group being triggered. - cmd: (int) The command byte being sent. + msg: (InptStandard) Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. """ # Make sure we're really a responder to this message. This # shouldn't ever occur. - entry = self.db.find(addr, group, is_controller=False) + entry = self.db.find(addr, msg.group, is_controller=False) if not entry: LOG.error("IOLinc %s has no group %s entry from %s", self.addr, - group, addr) + 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, cmd) + LOG.debug("IOLinc %s cmd %#04x", self.addr, msg.cmd1) #----------------------------------------------------------------------- def _set_is_on(self, is_on): diff --git a/insteon_mqtt/device/KeypadLinc.py b/insteon_mqtt/device/KeypadLinc.py index d260f870..d6853c8f 100644 --- a/insteon_mqtt/device/KeypadLinc.py +++ b/insteon_mqtt/device/KeypadLinc.py @@ -49,6 +49,10 @@ def __init__(self, protocol, modem, address, name, dimmer=True): # API: func(Device, int group, int level, on_off.Mode mode) self.signal_active = Signal() + # Manual mode start up, down, off + # API: func(Device, int group, on_off.Manual mode) + self.signal_manual = Signal() + # Remote (mqtt) commands mapped to methods calls. Add to the # base class defined commands. self.cmd_map.update({ @@ -568,7 +572,6 @@ def handle_broadcast(self, msg): # Non-group 1 messages are for the scene buttons on keypadlinc. # Treat those the same as the remote control does. They don't have # levels to find/set but have similar messages to the dimmer load. - cmd = msg.cmd1 # ACK of the broadcast - ignore this. Unless we sent a simulated off # scene in which case run the broadcast done handler. This is a @@ -598,22 +601,30 @@ def handle_broadcast(self, msg): # Notify others that the button was pressed. self._set_level(msg.group, 0x00, mode) - # Starting manual increment (cmd2 0x00=up, 0x01=down) - elif cmd == 0x17: - LOG.info("KeypadLinc %s starting manual change grp: %s %s", - self.addr, msg.group, "UP" if msg.cmd2 == 0x00 else "DN") - - # Stopping manual increment (cmd2 = unused) - elif cmd == 0x18: - LOG.info("KeypadLinc %s stopping manual change grp %s", self.addr, - msg.group) - - # Ping the device to get the button states - we don't know what - # the keypadlinc things the state is - could be on or off. Doing - # a dim down for a long time puts all the other devices "off" but - # the keypadlinc can still think that it's on. So we have to do - # a refresh to find out. - self.refresh() + # Starting or stopping manual increment (cmd2 0x00=up, 0x01=down) + elif on_off.Manual.is_valid(msg.cmd1): + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + LOG.info("KeypadLinc %s manual change %s", self.addr, manual) + + self.signal_manual.emit(self, msg.group, manual) + + # Non-group 1 buttons don't change state in manual mode. (found + # through experiments) + if msg.group == 1: + # Switches change state when the switch is held. + if not is_dimmer: + if manual == on_off.Manual.UP: + self._set_level(0xff, on_off.Mode.MANUAL) + elif manual == on_off.Manual.DOWN: + self._set_is_on(0x00, on_off.Mode.MANUAL) + + # Ping the device to get the dimmer states - we don't know + # what the keypadlinc things the state is - could be on or + # off. Doing a dim down for a long time puts all the other + # devices "off" but the keypadlinc can still think that it's + # on. So we have to do a refresh to find out. + elif manual == on_off.Manual.STOP: + self.refresh() # Call the base class handler. This will find all the devices we're # the controller of for this group and call their handle_group_cmd() @@ -690,7 +701,7 @@ def handle_increment(self, msg, on_done, delta): on_done(False, "KeypadLinc %s state update failed", None) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. This is called when this device is a responder to a scene. The @@ -703,6 +714,9 @@ def handle_group_cmd(self, addr, group, cmd): msg: (message.InpStandard) The broadcast message that was sent. Use msg.group to find the scene group that was broadcast. """ + group = msg.group + cmd1 = msg.cmd1 + # Make sure we're really a responder to this message. This shouldn't # ever occur. entry = self.db.find(addr, group, is_controller=False) @@ -712,8 +726,8 @@ def handle_group_cmd(self, addr, group, cmd): return # Handle on/off codes - if on_off.Mode.is_valid(cmd): - is_on, mode = on_off.Mode.decode(cmd) + if on_off.Mode.is_valid(cmd1): + is_on, mode = on_off.Mode.decode(cmd1) level = 0xff if is_on else 0x00 if self.is_dimmer and is_on and group == 1: level = entry.data[0] @@ -721,26 +735,26 @@ def handle_group_cmd(self, addr, group, cmd): self._set_level(group, level, mode) # Increment up (32 steps) - elif cmd == 0x15: + elif cmd1 == 0x15: assert group == 0x01 self._set_level(group, min(0xff, self._level + 8)) # Increment down - elif cmd == 0x16: + elif cmd1 == 0x16: assert group == 0x01 self._set_level(group, max(0x00, self._level - 8)) - # Starting manual increment (cmd2 0x00=up, 0x01=down) - elif cmd == 0x17: - pass + # Starting/stopping manual increment (cmd2 0x00=up, 0x01=down) + elif on_off.Manual.is_valid(msg.cmd1): + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + self.signal_manual.emit(self, group, manual) - # Stopping manual increment (cmd2 = unused) - elif cmd == 0x18: # Ping the light to get the new level - self.refresh() + if manual == on_off.Manual.STOP: + self.refresh() else: - LOG.warning("KeypadLinc %s unknown cmd %#04x", self.addr, cmd) + LOG.warning("KeypadLinc %s unknown cmd %#04x", self.addr, cmd1) #----------------------------------------------------------------------- def _set_level(self, group, level, mode=on_off.Mode.NORMAL): diff --git a/insteon_mqtt/device/Outlet.py b/insteon_mqtt/device/Outlet.py index 1180cba1..8176cdf1 100644 --- a/insteon_mqtt/device/Outlet.py +++ b/insteon_mqtt/device/Outlet.py @@ -467,7 +467,7 @@ def handle_scene(self, msg, on_done): on_done(False, "Scene trigger failed failed", None) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. This is called when this device is a responder to a scene. @@ -477,22 +477,23 @@ def handle_group_cmd(self, addr, group, cmd): Args: addr: (Address) The device that sent the message. This is the controller in the scene. - group: (int) The group being triggered. - cmd: (int) The command byte being sent. + msg: (InptStandard) Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. """ # Make sure we're really a responder to this message. This # shouldn't ever occur. - entry = self.db.find(addr, group, is_controller=False) + entry = self.db.find(addr, msg.group, is_controller=False) if not entry: LOG.error("Outlet %s has no group %s entry from %s", self.addr, - group, addr) + msg.group, addr) return - if on_off.Mode.is_valid(cmd): - is_on, mode = on_off.Mode.decode(cmd) - self._set_is_on(group, is_on, mode) + if on_off.Mode.is_valid(msg.cmd1): + is_on, mode = on_off.Mode.decode(msg.cmd1) + self._set_is_on(msg.group, is_on, mode) else: - LOG.warning("Outlet %s unknown group cmd %#04x", self.addr, cmd) + LOG.warning("Outlet %s unknown group cmd %#04x", self.addr, + msg.cmd1) #----------------------------------------------------------------------- def _set_is_on(self, group, is_on, mode=on_off.Mode.NORMAL): diff --git a/insteon_mqtt/device/Remote.py b/insteon_mqtt/device/Remote.py index 79406ee0..5bdb8354 100644 --- a/insteon_mqtt/device/Remote.py +++ b/insteon_mqtt/device/Remote.py @@ -71,6 +71,10 @@ def __init__(self, protocol, modem, address, name, num_button): # (Device, int group, bool on, on_off.Mode mode) self.signal_pressed = Signal() + # Manual mode start up, down, off + # API: func(Device, int group, on_off.Manual mode) + self.signal_manual = Signal() + #----------------------------------------------------------------------- def pair(self, on_done=None): """Pair the device with the modem. @@ -131,11 +135,8 @@ def handle_broadcast(self, msg): Args: msg: (InptStandard) Broadcast message from the device. """ - is_on, mode = None, on_off.Mode.NORMAL - cmd = msg.cmd1 - # ACK of the broadcast - ignore this. - if cmd == 0x06: + if msg.cmd1 == 0x06: LOG.info("Remote %s broadcast ACK grp: %s", self.addr, msg.group) return @@ -145,21 +146,16 @@ def handle_broadcast(self, msg): LOG.info("Remote %s broadcast grp: %s on: %s mode: %s", self.addr, msg.group, is_on, mode) - # Starting manual increment (cmd2 0x00=up, 0x01=down) - elif cmd == 0x17: - # This is kind of arbitrary - but if the button is held - # down we'll emit an on signal if it's dimming up and an - # off signal if it's dimming down. - is_on = msg.cmd2 == 0x00 # on = up, off = down + # Notify others that the button was pressed. + self.signal_pressed.emit(self, msg.group, is_on, mode) - # Stopping manual increment (cmd2 = unused) - elif cmd == 0x18: - # Nothing to do - the remote has no state to query about. - pass + # Starting or stopping manual increment (cmd2 0x00=up, 0x01=down) + elif on_off.Manual.is_valid(msg.cmd1): + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + LOG.info("Remote %s manual change group: %s %s", self.addr, + msg.group, manual) - # Notify others that the button was pressed. - if is_on is not None: - self.signal_pressed.emit(self, msg.group, is_on, mode) + 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 diff --git a/insteon_mqtt/device/Switch.py b/insteon_mqtt/device/Switch.py index 5379895c..0e8e5460 100644 --- a/insteon_mqtt/device/Switch.py +++ b/insteon_mqtt/device/Switch.py @@ -71,6 +71,10 @@ def __init__(self, protocol, modem, address, name=None): # API: func(Device, bool is_on, on_off.Mode mode) self.signal_active = Signal() + # Manual mode start up, down, off + # API: func(Device, on_off.Manual mode) + self.signal_manual = Signal() + # Remove (mqtt) commands mapped to methods calls. Add to the # base class defined commands. self.cmd_map.update({ @@ -343,6 +347,20 @@ def handle_broadcast(self, msg): elif not self.broadcast_done: self._set_is_on(False, mode) + # manual mode (holding buttons down) + # Starting or stopping manual increment (cmd2 0x00=up, 0x01=down) + elif on_off.Manual.is_valid(msg.cmd1): + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + LOG.info("Switch %s manual change %s", self.addr, manual) + + self.signal_manual.emit(self, manual) + + # Switches change state when the switch is held. + if manual == on_off.Manual.UP: + self._set_is_on(True, on_off.Mode.MANUAL) + elif manual == on_off.Manual.DOWN: + self._set_is_on(False, on_off.Mode.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 @@ -418,7 +436,7 @@ def handle_scene(self, msg, on_done): None) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, group, cmd): + def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. This is called when this device is a responder to a scene. @@ -428,22 +446,26 @@ def handle_group_cmd(self, addr, group, cmd): Args: addr: (Address) The device that sent the message. This is the controller in the scene. - group: (int) The group being triggered. - cmd: (int) The command byte being sent. + msg: (InptStandard) Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. """ # Make sure we're really a responder to this message. This # shouldn't ever occur. - entry = self.db.find(addr, group, is_controller=False) + entry = self.db.find(addr, msg.group, is_controller=False) if not entry: LOG.error("Switch %s has no group %s entry from %s", self.addr, - group, addr) + msg.group, addr) return - if on_off.Mode.is_valid(cmd): - is_on, mode = on_off.Mode.decode(cmd) + # Unlike dimmer, I don't think that an on/off switch can participate + # in a manual mode group on/off command so that handling isn't here. + if on_off.Mode.is_valid(msg.cmd1): + is_on, mode = on_off.Mode.decode(msg.cmd1) self._set_is_on(is_on, mode) + else: - LOG.warning("Switch %s unknown group cmd %#04x", self.addr, cmd) + LOG.warning("Switch %s unknown group cmd %#04x", self.addr, + msg.cmd1) #----------------------------------------------------------------------- def _set_is_on(self, is_on, mode=on_off.Mode.NORMAL): diff --git a/insteon_mqtt/handler/ModemScene.py b/insteon_mqtt/handler/ModemScene.py index 01b88f6f..5a7c4241 100644 --- a/insteon_mqtt/handler/ModemScene.py +++ b/insteon_mqtt/handler/ModemScene.py @@ -61,7 +61,7 @@ def msg_received(self, protocol, msg): elif isinstance(msg, Msg.InpAllLinkStatus): if msg.is_ack: LOG.debug("Modem scene %s command ACK", self.msg.group) - self.modem.handle_scene(self.msg.group, self.msg.cmd1) + self.modem.handle_scene(self.msg) self.on_done(True, "Scene command complete", None) else: self.on_done(False, "Scene command failed", None) diff --git a/insteon_mqtt/mqtt/Dimmer.py b/insteon_mqtt/mqtt/Dimmer.py index 5cd75a6e..619466a8 100644 --- a/insteon_mqtt/mqtt/Dimmer.py +++ b/insteon_mqtt/mqtt/Dimmer.py @@ -32,6 +32,9 @@ def __init__(self, mqtt, device): payload='{ "state" : "{{on_str.upper()}}", ' '"brightness" : {{level_255}} }') + # Output manual state change is off by default. + self.msg_manual_state = MsgTemplate(None, None) + # Input level command template. self.msg_level = MsgTemplate( topic='insteon/{{address}}/level', @@ -44,6 +47,7 @@ def __init__(self, mqtt, device): payload='{ "cmd" : "{{value.lower()}}" }') device.signal_level_changed.connect(self.handle_level_changed) + device.signal_manual.connect(self.handle_manual) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -123,6 +127,16 @@ def template_data(self, level=None, mode=on_off.Mode.NORMAL): return data + #----------------------------------------------------------------------- + def manual_template_data(self, manual): + """TODO: doc + """ + data = self.template_data() + data["manual_str"] = str(manual) + data["manual"] = manual.int_value() + data["manual_openhab"] = manual.openhab_value() + return data + #----------------------------------------------------------------------- def handle_level_changed(self, device, level, mode=on_off.Mode.NORMAL): """Device active on/off callback. @@ -140,6 +154,23 @@ def handle_level_changed(self, device, level, mode=on_off.Mode.NORMAL): data = self.template_data(level, mode) self.msg_state.publish(self.mqtt, data) + #----------------------------------------------------------------------- + def handle_manual(self, device, manual): + """Device manual mode callback. + + This is triggered via signal when the Insteon device goes + active or inactive. It will publish an MQTT message with the + new state. + + Args: + device: (device.Base) The Insteon device that changed. + manual: (on_off.Manual) The manual mode. + """ + LOG.info("MQTT received manual change %s = %s", device.label, manual) + + data = self.manual_template_data(manual) + self.msg_manual_state.publish(self.mqtt, data) + #----------------------------------------------------------------------- def handle_set_level(self, client, data, message): """TODO: doc diff --git a/insteon_mqtt/mqtt/KeypadLinc.py b/insteon_mqtt/mqtt/KeypadLinc.py index 8248b5c2..d1346536 100644 --- a/insteon_mqtt/mqtt/KeypadLinc.py +++ b/insteon_mqtt/mqtt/KeypadLinc.py @@ -26,6 +26,9 @@ def __init__(self, mqtt, device): topic='insteon/{{address}}/state/{{button}}', payload='{{on_str.lower()}}') + # Output manual state change is off by default. + self.msg_manual_state = MsgTemplate(None, None) + # Input on/off command template. self.msg_btn_on_off = MsgTemplate( topic='insteon/{{address}}/set/{{button}}', @@ -52,6 +55,7 @@ def __init__(self, mqtt, device): '"level" : {{json.brightness}} }') device.signal_active.connect(self.handle_active) + device.signal_manual.connect(self.handle_manual) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -68,6 +72,8 @@ def load_config(self, config, qos=None): self.msg_btn_state.load_config(data, 'btn_state_topic', 'btn_state_payload', qos) + self.msg_manual_state.load_config(data, 'manual_state_topic', + 'manual_state_payload', qos) self.msg_btn_on_off.load_config(data, 'btn_on_off_topic', 'btn_on_off_payload', qos) self.msg_btn_scene.load_config(data, 'btn_scene_topic', @@ -148,7 +154,7 @@ def unsubscribe(self, link): #----------------------------------------------------------------------- # pylint: disable=arguments-differ - def template_data(self, level=None, button=None, + def template_data(self, button=None, level=None, mode=on_off.Mode.NORMAL): """TODO: doc """ @@ -170,6 +176,57 @@ def template_data(self, level=None, button=None, return data + #----------------------------------------------------------------------- + def manual_template_data(self, button, manual): + """TODO: doc + """ + data = self.template_data(button) + data["manual_str"] = str(manual) + data["manual"] = manual.int_value() + data["manual_openhab"] = manual.openhab_value() + return data + + #----------------------------------------------------------------------- + def handle_active(self, device, group, level, mode=on_off.Mode.NORMAL): + """Device active button pressed callback. + + This is triggered via signal when the Insteon device button is + pressed. It will publish an MQTT message with the button + number. + + Args: + device: (device.Base) The Insteon device that changed. + group: (int) The button number 1...n that was pressed. + level: (int) The current device level 0...0xff. + """ + LOG.info("MQTT received button press %s = btn %s at %s %s", + device.label, group, level, mode) + + data = self.template_data(group, level, mode) + + if group == 1 and self.device.is_dimmer: + self.msg_dimmer_state.publish(self.mqtt, data) + else: + self.msg_btn_state.publish(self.mqtt, data) + + #----------------------------------------------------------------------- + def handle_manual(self, device, group, manual): + """Device manual mode callback. + + This is triggered via signal when the Insteon device goes + active or inactive. It will publish an MQTT message with the + new state. + + Args: + device: (device.Base) The Insteon device that changed. + manual: (on_off.Manual) The manual mode. + """ + LOG.info("MQTT received manual button press %s = btn %s %s", + device.label, group, manual) + + data = self.manual_template_data(group, manual) + self.msg_manual_state.publish(self.mqtt, data) + #----------------------------------------------------------------------- def handle_set(self, client, data, message, group): """TODO: doc @@ -256,26 +313,3 @@ def handle_scene(self, client, data, message, group): LOG.exception("Invalid KeypadLinc command: %s", data) #----------------------------------------------------------------------- - def handle_active(self, device, group, level, mode=on_off.Mode.NORMAL): - """Device active button pressed callback. - - This is triggered via signal when the Insteon device button is - pressed. It will publish an MQTT message with the button - number. - - Args: - device: (device.Base) The Insteon device that changed. - group: (int) The button number 1...n that was pressed. - level: (int) The current device level 0...0xff. - """ - LOG.info("MQTT received button press %s = btn %s at %s %s", - device.label, group, level, mode) - - data = self.template_data(level, group, mode) - - if group == 1 and self.device.is_dimmer: - self.msg_dimmer_state.publish(self.mqtt, data) - else: - self.msg_btn_state.publish(self.mqtt, data) - - #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/Remote.py b/insteon_mqtt/mqtt/Remote.py index f577db8b..6e8ad5c2 100644 --- a/insteon_mqtt/mqtt/Remote.py +++ b/insteon_mqtt/mqtt/Remote.py @@ -23,7 +23,11 @@ def __init__(self, mqtt, device): topic='insteon/{{address}}/state/{{button}}', payload='{{on_str.lower()}}') + # Output manual state change is off by default. + self.msg_manual_state = MsgTemplate(None, None) + device.signal_pressed.connect(self.handle_pressed) + device.signal_manual.connect(self.handle_manual) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -39,6 +43,8 @@ def load_config(self, config, qos=None): return self.msg_state.load_config(data, 'state_topic', 'state_payload', qos) + self.msg_manual_state.load_config(data, 'manual_state_topic', + 'manual_state_payload', qos) #----------------------------------------------------------------------- def subscribe(self, link, qos): @@ -60,7 +66,7 @@ def unsubscribe(self, link): pass #----------------------------------------------------------------------- - def template_data(self, button, is_on, mode=on_off.Mode.NORMAL): + def template_data(self, button, is_on=None, mode=on_off.Mode.NORMAL): """TODO: doc """ # Set up the variables that can be used in the templates. @@ -69,12 +75,25 @@ def template_data(self, button, is_on, mode=on_off.Mode.NORMAL): "name" : self.device.name if self.device.name else self.device.addr.hex, "button" : button, - "on" : 1 if is_on else 0, - "on_str" : "on" if is_on else "off", - "mode" : str(mode), - "fast" : 1 if mode == on_off.Mode.FAST else 0, - "instant" : 1 if mode == on_off.Mode.INSTANT else 0, } + + if is_on is not None: + data["on"] = 1 if is_on else 0 + data["on_str"] = "on" if is_on else "off" + data["mode"] = str(mode) + data["fast"] = 1 if mode == on_off.Mode.FAST else 0 + data["instant"] = 1 if mode == on_off.Mode.INSTANT else 0 + + return data + + #----------------------------------------------------------------------- + def manual_template_data(self, button, manual): + """TODO: doc + """ + data = self.template_data(button) + data["manual_str"] = str(manual) + data["manual"] = manual.int_value() + data["manual_openhab"] = manual.openhab_value() return data #----------------------------------------------------------------------- @@ -96,3 +115,21 @@ def handle_pressed(self, device, button, is_on, mode=on_off.Mode.NORMAL): self.msg_state.publish(self.mqtt, data) #----------------------------------------------------------------------- + def handle_manual(self, device, group, manual): + """Device manual mode callback. + + This is triggered via signal when the Insteon device goes + active or inactive. It will publish an MQTT message with the + new state. + + Args: + device: (device.Base) The Insteon device that changed. + manual: (on_off.Manual) The manual mode. + """ + LOG.info("MQTT received manual button press %s = btn %s %s", + device.label, group, manual) + + data = self.manual_template_data(group, manual) + self.msg_manual_state.publish(self.mqtt, data) + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/Switch.py b/insteon_mqtt/mqtt/Switch.py index b21ac9b8..b6a27881 100644 --- a/insteon_mqtt/mqtt/Switch.py +++ b/insteon_mqtt/mqtt/Switch.py @@ -66,6 +66,9 @@ def __init__(self, mqtt, device, handle_active=True): topic='insteon/{{address}}/state', payload='{{on_str.lower()}}') + # Output manual state change is off by default. + self.msg_manual_state = MsgTemplate(None, None) + # Input on/off command template. self.msg_on_off = MsgTemplate( topic='insteon/{{address}}/set', @@ -79,6 +82,7 @@ def __init__(self, mqtt, device, handle_active=True): # Receive notifications from the Insteon device when it changes. if handle_active: device.signal_active.connect(self.handle_active) + device.signal_manual.connect(self.handle_manual) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -100,6 +104,8 @@ def load_switch_config(self, config, qos): # Update the MQTT topics and payloads from the config file. self.msg_state.load_config(config, 'state_topic', 'state_payload', qos) + self.msg_manual_state.load_config(config, 'manual_state_topic', + 'manual_state_payload', qos) self.msg_on_off.load_config(config, 'on_off_topic', 'on_off_payload', qos) self.msg_scene_on_off.load_config(config, 'scene_on_off_topic', @@ -152,6 +158,16 @@ def template_data(self, is_on=None, mode=on_off.Mode.NORMAL): return data + #----------------------------------------------------------------------- + def manual_template_data(self, manual): + """TODO: doc + """ + data = self.template_data() + data["manual_str"] = str(manual) + data["manual"] = manual.int_value() + data["manual_openhab"] = manual.openhab_value() + return data + #----------------------------------------------------------------------- def handle_active(self, device, is_on, mode=on_off.Mode.NORMAL): """Device active on/off callback. @@ -171,6 +187,23 @@ def handle_active(self, device, is_on, mode=on_off.Mode.NORMAL): self.msg_state.publish(self.mqtt, data) + #----------------------------------------------------------------------- + def handle_manual(self, device, manual): + """Device manual mode callback. + + This is triggered via signal when the Insteon device goes + active or inactive. It will publish an MQTT message with the + new state. + + Args: + device: (device.Base) The Insteon device that changed. + manual: (on_off.Manual) The manual mode. + """ + LOG.info("MQTT received manual change %s = %s", device.label, manual) + + data = self.manual_template_data(manual) + self.msg_manual_state.publish(self.mqtt, data) + #----------------------------------------------------------------------- def handle_on_off(self, client, data, message): """TODO: doc diff --git a/insteon_mqtt/on_off.py b/insteon_mqtt/on_off.py index 0e9a237c..52adb903 100644 --- a/insteon_mqtt/on_off.py +++ b/insteon_mqtt/on_off.py @@ -17,6 +17,7 @@ class Mode(enum.Enum): NORMAL = "normal" FAST = "fast" INSTANT = "instant" + # this is manual load status change, not holding down a button MANUAL = "manual" def __str__(self): @@ -67,6 +68,9 @@ def decode(cmd): return result #=========================================================================== +# It would be better if these were part of Mode - but python < 3.7 doesn't +# support adding attributes to an enum that are enumeration values. + # Map command code to [is_on, Mode enum] _cmdMap = {0x11 : [True, Mode.NORMAL], @@ -85,3 +89,92 @@ def decode(cmd): # Instant off is the same as instant on, just with the level set to 0x00. _offCode[Mode.INSTANT] = _onCode[Mode.INSTANT] + + +#=========================================================================== +class Manual(enum.Enum): + """On/Off manual mode enumeration. + + There are the various manual mode commands that Insteon devices send when + a button is held down. UP or DOWN is sent when pressed, OFF is sent when + the button is released. + """ + UP = "up" + DOWN = "down" + STOP = "stop" + + def __str__(self): + return self.value + + @staticmethod + def is_valid(cmd): + """See if a command code is a valid on/off code. + Args: + cmd: (int) The Insteon command code + Returns: + Returns True if the input is a valid on/off code + """ + return cmd in (0x17, 0x18) + + @staticmethod + def encode(manual): + """Convert manual enumeration to a pair of command codes. + + Args: + manual: (Mode) The mode enumeration to use. + Returns: + (int, int) Returns the Insteon code 1 and code 2 for the input. + """ + assert isinstance(manual, Manual) + if manual is Manual.STOP: + return 0x18, 0x00 + elif manual is Manual.UP: + return 0x17, 0x01 + else: + return 0x17, 0x00 + + @staticmethod + def decode(cmd1, cmd2): + """Convert a pair of command codes to manual enumeration. + + If the input isn't a valid command code, an Exception is thrown. + + Args: + cmd1: (int) The Insteon command code 1. 0x17=start, 0x18=stop + cmd2: (int) The Insteon command code 2. Ignored for stop. + For start, 0x01=up, 0x00=down. + + Returns: + (Manual) Returns the manual enum. + """ + if cmd1 == 0x18: + return Manual.STOP + elif cmd1 == 0x17: + if cmd2 == 0x01: + return Manual.UP + elif cmd2 == 0x00: + return Manual.DOWN + + raise Exception("Invalid manual command %s, %s." % (cmd1, cmd2)) + + def int_value(self): + """Return an integer value of the command code. + UP = +1, STOP = 0, DOWN = -1 + """ + if self is Manual.UP: + return +1 + elif self is Manual.DOWN: + return -1 + else: + return 0 + + def openhab_value(self): + """Return an integer value of the command code for OpenHab. + OpenHab uses UP = 2, STOP = 1, DOWN = 0. + """ + if self is Manual.UP: + return 2 + elif self is Manual.DOWN: + return 0 + else: + return 1 diff --git a/tests/test_on_off.py b/tests/test_on_off.py index 00ea4427..2b9b9c3e 100644 --- a/tests/test_on_off.py +++ b/tests/test_on_off.py @@ -73,3 +73,51 @@ def test_decode(): with pytest.raises(Exception): IM.on_off.Mode.decode(0xff) + + +#=========================================================================== +def test_manual_is_valid(): + for cmd in [0x17, 0x18]: + assert IM.on_off.Manual.is_valid( cmd ) + + assert not IM.on_off.Manual.is_valid( 0x00 ) + assert not IM.on_off.Manual.is_valid( 0x50 ) + + +#=========================================================================== +def test_manual_encode(): + cmd1, cmd2 = IM.on_off.Manual.encode(IM.on_off.Manual.UP) + assert cmd1 == 0x17 + assert cmd2 == 0x01 + + cmd1, cmd2 = IM.on_off.Manual.encode(IM.on_off.Manual.DOWN) + assert cmd1 == 0x17 + assert cmd2 == 0x00 + + cmd1, cmd2 = IM.on_off.Manual.encode(IM.on_off.Manual.STOP) + assert cmd1 == 0x18 + assert cmd2 == 0x00 + + +#=========================================================================== +def test_manual_decode(): + mode = IM.on_off.Manual.decode(0x17, 0x01) + assert mode == IM.on_off.Manual.UP + str(mode) + assert mode.int_value() == +1 + assert mode.openhab_value() == 2 + + mode = IM.on_off.Manual.decode(0x17, 0x00) + assert mode == IM.on_off.Manual.DOWN + str(mode) + assert mode.int_value() == -1 + assert mode.openhab_value() == 0 + + mode = IM.on_off.Manual.decode(0x18, 0x00) + assert mode == IM.on_off.Manual.STOP + str(mode) + assert mode.int_value() == 0 + assert mode.openhab_value() == 1 + + with pytest.raises(Exception): + IM.on_off.Manual.decode(0xff, 0x00)