diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 62ca9d83..ec2dbd01 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.6 +current_version = 0.8.0 commit = True tag = False diff --git a/CHANGELOG.md b/CHANGELOG.md index fb736003..7f4a4bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Revision Change History +## [0.8.0] + +This update is the culmination of a significant refactor of the underlying +code including PRs #308, #320, #325, #330, and #334 as well as other smaller +PRs. These changes began back in 0.7.6 and should be transparent to the end +user. But they should make the underlying code easier to maintain and simpler +to add new devices and features. This is the reason for the jump to 0.8.0. + +### Additions + +- Added support for hidden door sensors. (thanks @tommycw1) ([PR 324][P324]) + +- Adds a version command to the modem command topic `{"cmd":"version"}` and + to the command line `insteon-mqtt config.yaml -v`. Both will return the + version of Insteon-MQTT. ([PR 355][P355]) + +- Adds get_flags suppport to the modem. Only needed for debugging. + ([PR 359][P359]) + +### Fixes + +- Fixed an error where setting the MQTT id would prevent subscribing to + MQTT topics. (thanks @kpfleming)([PR 352][P352]) + +- Updates hassio config to comply with changes in API keys. ([PR 344][P344]) + +- Fix small bug in BroadcastCmdResponse handler that likely never affected + anyone. ([PR 354][P354]) + ## [0.7.6] A few new features and some significant bug fixes. A significant refactor of @@ -590,3 +619,9 @@ will add new features. [P313]: https://github.com/TD22057/insteon-mqtt/pull/313 [P213]: https://github.com/TD22057/insteon-mqtt/pull/213 [P326]: https://github.com/TD22057/insteon-mqtt/pull/326 +[P352]: https://github.com/TD22057/insteon-mqtt/pull/352 +[P344]: https://github.com/TD22057/insteon-mqtt/pull/344 +[P324]: https://github.com/TD22057/insteon-mqtt/pull/324 +[P354]: https://github.com/TD22057/insteon-mqtt/pull/354 +[P355]: https://github.com/TD22057/insteon-mqtt/pull/355 +[P359]: https://github.com/TD22057/insteon-mqtt/pull/359 diff --git a/README.md b/README.md index c0c0484a..4224ded6 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.6 ([History](CHANGELOG.md)) +Version: 0.8.0 ([History](CHANGELOG.md)) ### Recent Breaking Changes diff --git a/config.json b/config.json index 35df3c7c..773f9e7d 100644 --- a/config.json +++ b/config.json @@ -2,11 +2,11 @@ "name": "Insteon MQTT", "description": "Creates an MQTT interface to the Insteon protocol.", "slug": "insteon-mqtt", - "version": "0.7.6", + "version": "0.8.0", "startup": "services", "arch": ["amd64","armhf","aarch64","i386"], "boot": "auto", - "auto_uart": true, + "uart": true, "map": ["config:rw"], "options": {}, "schema": {}, diff --git a/config.yaml b/config.yaml index 1c9eb54d..f700d4ad 100644 --- a/config.yaml +++ b/config.yaml @@ -89,10 +89,14 @@ insteon: - 48.3d.46 - 48.b0.ad: 'dim1' - # Battery powered sensors (door, hidden door, window). + # Battery powered sensors (door, window). battery_sensor: - 94.a9.12 + # Battery powered hidden door sensors + hidden_door: + - 32.a0.4b: 'back' + # Battery powered motion sensors. motion: - 21.d6.d9: 'door' @@ -449,6 +453,32 @@ mqtt: dawn_dusk_topic: 'insteon/{{address}}/dawn' dawn_dusk_payload: '{{is_dawn_str.upper()}}' + #------------------------------------------------------------------------ + # Hidden Door Sensors + #------------------------------------------------------------------------ + + # Hidden Door Sensors will use the state and low battery configuration + # inputs from battery_sensor and add battery voltage which is configured + # here. + # + # To register the hidden door sensor in Home Assistant use MQTT binary + # sensor with a configuration like: + # binary_sensor: + # - platform: mqtt + # state_topic: 'insteon/aa.bb.cc//battery_voltage' + #TODO device_class: 'light' + hidden_door: + # Output topic and payload. This message is sent + # whenever the device is polled and a new voltage, low battery voltage + # or heart beat interval is obtained from the device. + # Available variables for templating are: + # address = 'aa.bb.cc' + # name = 'device name' + # batt_volt = raw insteon voltage level + + battery_voltage_topic: 'insteon/{{address}}/battery_voltage' + battery_voltage_payload: '{"voltage" : {{batt_volt}}}' + #------------------------------------------------------------------------ # Leak sensors #------------------------------------------------------------------------ diff --git a/docs/mqtt.md b/docs/mqtt.md index 65fabb61..34bf37f5 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -30,6 +30,7 @@ be identified by it's address or the string "modem". - [Battery Sensors](#battery-sensors) - [Dimmers](#dimmers) - [FanLinc](#fanlinc) + - [Hidden Door Sensors](#hidden-door-sensors) - [IOLinc](#iolinc) - [KeypadLinc](#keypadlinc) - [Leak Sensors](#leak-sensors) @@ -98,6 +99,27 @@ Alternatively you can use the nice names from the config.yaml file too: insteon/command/NICE NAME ``` +### Print the Version of Insteon-MQTT + +Supported: modem + +If requesting help or submitting a bug, you can use this command to get the +version of Insteon-MQTT that you are running: + + The default topic location is: + + `insteon/command/modem` + + ``` + { "cmd" : "version"} + ``` + +This command can also be run from the command line: + + ``` + insteon-mqtt -v + ``` + ### Join a New Device to the Network Supported: devices @@ -431,7 +453,19 @@ commands. ### Get and set operating flags. -Supported: devices +Supported: devices, modem (get_flags only) + +To request the current flag settings on a device: + + ``` + insteon-mqtt config.yaml get-flags aa.bb.cc + ``` + +The MQTT format of the command is: + + ``` + { "cmd" : "get_flags"} + ``` This command gets and sets various Insteon device flags. The set of supported flags depends on the device type. The command line tool accepts an @@ -1060,6 +1094,118 @@ A sample motion sensor topic and payload configuration is: --- +## Hidden Door Sensors + +Hidden Door sensors do not accept any input commands in their normal "off" +state. If you press and hold the link button for about 3 seconds it will +beep and its small LED will blink. It will be awake for about the next 4 +minutes where you can download the db and or configure the units many +options. All configuration option offered by this device are available for +configuration here. The open/closed (one group) and low battery are +inherited from the battery sensor inputs. The hidden door sensor adds +another possible state change the 2 groups configuration where it will report +open as ON on group 0x01 and closed as ON on group 0x02. The raw Insteon +reported battery voltage level is reported over MQTT. + +Note: The Insteon Dev notes provide 4 points for correlation of this raw +battery level to actual battery voltages. + + 61=~1.75V + 54=~1.6V + 51=~1.5V + 40=~1.25V (default low battery mark) + +The following variable is available for templating: + + 'batt_volt' is the raw Insteon voltage level + +A sample hidden door sensor topic and payload configuration is: + + ``` + hidden_door: + battery_voltage_topic: 'insteon/{{address}}/battery_voltage' + battery_voltage_payload: '{"voltage" : {{batt_volt}}}' + ``` + +To set configuration option on the device, first press and hold the device link +button until it beeps and the LED starts flashing. Next tell insteon-mqtt the +device is awake with: + + ``` + { "cmd" : "awake" } + ``` + +This will tell insteon-mqtt to send commands right away rather than queuing +until the deice is awake. + +View Current configuration in log: + + ``` + { "cmd" : "get_flags" } + ``` + +This configuration can be changed with the following command: + + ``` + { "cmd" : "set_flags", "key" : value } + + ``` + +An example to turn on two groups: + + ``` + { "cmd" : "set_flags", "two_groups" : 1 }' + ``` + +Configuration is available for the following options: + +The following key/value pairs are available: + + - cleanup_report = 1/0: tell the device whether or not to send cleanup + reports + + - led_disable = 1/0: disables small led on back of device to blink on + state change + + - link_to_all = 1/0: links to 0xFF group (all available groups) + + - two_groups = 1/0: Report open/close on group 1 or report open on group 1 + and closed on 2 + + - prog_lock = 1/0: prevents device from being programmed by local button + presses + + - repeat_closed = 1/0: Repeat open command every 5 mins for 50 mins + + - repeat_open = 1/0: Repeat open command every 5 mins for 50 mins + + - stay_awake = 1/0: keeps device awake - but uses a lot of battery + +Beyond these flags there are two additional settings: + + - Low Battery threshold. This is the raw Insteon voltage level that where + the device will trigger a group 0x03 low battery warning. Example to set to + 64 below: + + ``` + { "cmd" : "set_low_battery_voltage", "voltage" : 64 } + ``` + + - Heart Beat Interval. The sensor will send a heartbeat to prove that it is + functional at a configurable interval. The more frequent it wakes up to + this group 0x04 message the faster the battery will deplete. + The time between heartbeats sent is 5 minutes x this setting. So setting + this value to 24 would be 24 x 5 mins = 120 mins. this can be set from + 0 -> 255. Setting to 0 = 24 hours or 1440 minutes. Example: to set to 10 + minutes below: + + ``` + { "cmd" : "set_heart_beat_interval", "interval" : 2 } + ``` + + +--- + ## Leak Sensors Leak sensors do not accept any input commands. The leak diff --git a/insteon_mqtt/Modem.py b/insteon_mqtt/Modem.py index f5f99be7..03e8eb95 100644 --- a/insteon_mqtt/Modem.py +++ b/insteon_mqtt/Modem.py @@ -7,6 +7,7 @@ import os import sys import functools +import insteon_mqtt from .Address import Address from .CommandSeq import CommandSeq from . import config @@ -86,10 +87,12 @@ def __init__(self, protocol, stack, timed_call): 'linking' : self.linking, 'scene' : self.scene, 'factory_reset' : self.factory_reset, + 'get_flags' : self.get_flags, 'sync_all' : self.sync_all, 'sync' : self.sync, 'import_scenes': self.import_scenes, - 'import_scenes_all': self.import_scenes_all + 'import_scenes_all': self.import_scenes_all, + 'version': self.version } # Add a generic read handler for any broadcast messages initiated by @@ -245,6 +248,16 @@ def get_addr(self, on_done=None): msg_handler = handler.ModemInfo(self, on_done) self.send(msg, msg_handler) + #----------------------------------------------------------------------- + def version(self, on_done=None): + """ Returns the version of insteon_mqtt + + Used by the MQTT command: + Default Topic: 'insteon/command/modem' + Payload: '{"cmd": "version"}' + """ + on_done(True, insteon_mqtt.__version__, None) + #----------------------------------------------------------------------- def refresh(self, force=False, on_done=None): """Load the all link database from the modem. @@ -700,6 +713,18 @@ def factory_reset(self, on_done=None): msg_handler = handler.ModemReset(self, on_done) self.send(msg, msg_handler) + #----------------------------------------------------------------------- + def get_flags(self, on_done=None): + """Queries and Prints the Modem Flags to the Log + + Args: + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + msg = Msg.OutGetModemFlags() + msg_handler = handler.ModemGetFlags(self, on_done) + self.send(msg, msg_handler) + #----------------------------------------------------------------------- def send(self, msg, msg_handler, high_priority=False, after=None): """Send a message to the modem. @@ -1026,15 +1051,9 @@ def link_data_from_pretty(self, is_controller, data): list[3]: List of Data1-3 values """ # For the base devices this does nothing - 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'] + data_1 = data.get('data_1', None) + data_2 = data.get('data_2', None) + data_3 = data.get('data_3', None) return [data_1, data_2, data_3] #----------------------------------------------------------------------- diff --git a/insteon_mqtt/__init__.py b/insteon_mqtt/__init__.py index c2545c02..290faefc 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.6" +__version__ = "0.8.0" #=========================================================================== diff --git a/insteon_mqtt/cmd_line/main.py b/insteon_mqtt/cmd_line/main.py index 46004917..76eb6e7a 100644 --- a/insteon_mqtt/cmd_line/main.py +++ b/insteon_mqtt/cmd_line/main.py @@ -5,6 +5,7 @@ #=========================================================================== import argparse import sys +import insteon_mqtt from .. import config from . import device from . import modem @@ -17,6 +18,8 @@ def parse_args(args): # pylint: disable=too-many-statements p = argparse.ArgumentParser(prog="insteon-mqtt", description="Insteon<->MQTT tool") + p.add_argument('-v', '--version', action='version', version='%(prog)s ' + + insteon_mqtt.__version__) p.add_argument("config", metavar="config.yaml", help="Configuration " "file to use.") sub = p.add_subparsers(help="Command help") diff --git a/insteon_mqtt/config.py b/insteon_mqtt/config.py index 17cc0ad3..0de2d7ea 100644 --- a/insteon_mqtt/config.py +++ b/insteon_mqtt/config.py @@ -20,9 +20,10 @@ 'battery_sensor' : (device.BatterySensor, {}), "ezio4o": (device.EZIO4O, {}), 'fan_linc' : (device.FanLinc, {}), + 'hidden_door' : (device.HiddenDoor, {}), 'io_linc' : (device.IOLinc, {}), - 'keypad_linc' : (device.KeypadLinc, {'dimmer' : True}), - 'keypad_linc_sw' : (device.KeypadLinc, {'dimmer' : False}), + 'keypad_linc' : (device.KeypadLincDimmer, {}), + 'keypad_linc_sw' : (device.KeypadLinc, {}), 'leak' : (device.Leak, {}), 'mini_remote1' : (device.Remote, {'num_button' : 1}), 'mini_remote4' : (device.Remote, {'num_button' : 4}), diff --git a/insteon_mqtt/device/BatterySensor.py b/insteon_mqtt/device/BatterySensor.py index 0cab8c25..bc62ffff 100644 --- a/insteon_mqtt/device/BatterySensor.py +++ b/insteon_mqtt/device/BatterySensor.py @@ -4,7 +4,7 @@ # #=========================================================================== import time -from .Base import Base +from .base import Base from .. import log from .. import message as Msg from ..Signal import Signal @@ -37,9 +37,6 @@ class BatterySensor(Base): connect to these signals to perform an action when a change is made to the device (like sending MQTT messages). Supported signals are: - - signal_state( Device, bool is_on ): Sent when the sensor is tripped - (is_on=True) or resets (is_on=False). - - signal_low_battery( Device, bool is_low ): Sent to indicate the current battery state. @@ -61,8 +58,6 @@ def __init__(self, protocol, modem, address, name=None): """ super().__init__(protocol, modem, address, name) - # Sensor on/off signal. API: func( Device, bool is_on ) - self.signal_state = Signal() # Sensor low battery signal. API: func( Device, bool is_low ) self.signal_low_battery = Signal() # Sensor heartbeat signal. API: func( Device, True ) @@ -83,7 +78,6 @@ def __init__(self, protocol, modem, address, name=None): 0x04 : self.handle_heartbeat, }) - self._is_on = False self._send_queue = [] self.cmd_map.update({ 'awake' : self.awake @@ -123,12 +117,6 @@ def send(self, msg, msg_handler, high_priority=False, after=None): LOG.ui("BatterySensor %s - queueing msg until awake", self.label) self._send_queue.append([msg, msg_handler, high_priority, after]) - #----------------------------------------------------------------------- - def is_on(self): - """Return if sensor has been tripped. - """ - return self._is_on - #----------------------------------------------------------------------- def handle_finished(self, msg): """Handle write messages that are marked FINISHED @@ -167,26 +155,6 @@ def handle_broadcast(self, msg): # Pop messages from _send_queue if necessary self._pop_send_queue() - #----------------------------------------------------------------------- - def handle_on_off(self, msg): - """Handle sensor activation. - - This is called by the device when a group broadcast on group 01 is - sent out by the sensor. - - Args: - msg (InpStandard): Broadcast message from the device. - """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("BatterySensor %s broadcast ACK grp: %s", self.addr, - msg.group) - else: - LOG.info("BatterySensor %s on_off broadcast cmd: %s", self.addr, - msg.cmd1) - self._set_is_on(msg.cmd1 == Msg.CmdType.ON) - self.update_linked_devices(msg) - #----------------------------------------------------------------------- def handle_low_battery(self, msg): """Handle a low battery message. @@ -198,16 +166,11 @@ def handle_low_battery(self, msg): msg (InpStandard): Broadcast message from the device. On/off is stored in msg.cmd1. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("BatterySensor %s broadcast ACK grp: %s", self.addr, - msg.group) - else: - LOG.info("BatterySensor %s low battery broadcast cmd: %s", - self.addr, msg.cmd1) - # Send True for low battery, False for regular. - self.signal_low_battery.emit(self, msg.cmd1 == Msg.CmdType.ON) - self.update_linked_devices(msg) + LOG.info("BatterySensor %s low battery broadcast cmd: %s", + self.addr, msg.cmd1) + # Send True for low battery, False for regular. + self.signal_low_battery.emit(self, msg.cmd1 == Msg.CmdType.ON) + self.update_linked_devices(msg) #----------------------------------------------------------------------- def handle_heartbeat(self, msg): @@ -219,35 +182,11 @@ def handle_heartbeat(self, msg): Args: msg (InpStandard): Broadcast message from the device. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("BatterySensor %s broadcast ACK grp: %s", self.addr, - msg.group) - else: - LOG.info("BatterySensor %s heartbeat broadcast cmd: %s", self.addr, - msg.cmd1) - # Send True for any heart beat message - self.signal_heartbeat.emit(self, True) - self.update_linked_devices(msg) - - #----------------------------------------------------------------------- - def handle_refresh(self, msg): - """Handle replies to the refresh command. - - The refresh command reply will contain the current device - state in cmd2 and this updates the device with that value. - - NOTE: refresh() will not work if the device is asleep. - - Args: - msg (message.InpStandard): The refresh message reply. The current - device state is in the msg.cmd2 field. - """ - LOG.ui("BatterySensor %s refresh on = %s", self.addr, msg.cmd2 != 0x00) - - # Current on/off level is stored in cmd2 so update our state - # to match. - self._set_is_on(msg.cmd2 != 0x00) + LOG.info("BatterySensor %s heartbeat broadcast cmd: %s", self.addr, + msg.cmd1) + # Send True for any heart beat message + self.signal_heartbeat.emit(self, True) + self.update_linked_devices(msg) #----------------------------------------------------------------------- def awake(self, on_done): @@ -274,20 +213,6 @@ def awake(self, on_done): self._send_queue = [] on_done(True, "Complete", None) - #----------------------------------------------------------------------- - def _set_is_on(self, is_on): - """Set the device on/off state. - - This will change the internal state and emit the state changed - signal. - - Args: - is_on (bool): True if motion is active, False if it isn't. - """ - LOG.info("Setting device %s on:%s", self.label, is_on) - self._is_on = is_on - self.signal_state.emit(self, is_on=self._is_on) - #----------------------------------------------------------------------- def _pop_send_queue(self): """Pops a messages off the _send_queue if necessary diff --git a/insteon_mqtt/device/Dimmer.py b/insteon_mqtt/device/Dimmer.py index 020f17e9..ac857b0f 100644 --- a/insteon_mqtt/device/Dimmer.py +++ b/insteon_mqtt/device/Dimmer.py @@ -4,21 +4,19 @@ # including wall switches, lamp modules, and some remotes. # #=========================================================================== -import functools -from .Base import Base -from . import functions +from .base import DimmerBase +from .functions import Scene, Backlight 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 LOG = log.get_logger() -class Dimmer(functions.Scene, functions.Set, Base): +class Dimmer(Scene, Backlight, DimmerBase): """Insteon dimmer device. This class can be used to model any device that acts like a dimmer @@ -26,25 +24,8 @@ class Dimmer(functions.Scene, functions.Set, Base): 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_state( Device, int level, on_off.Mode mode, str reason ): - Sent whenever the dimmer is turned on or off or changes level. The - level field will be in the range 0-255. - - - signal_manual( Device, on_off.Manual mode, str reason ): Sent when the - device starts or stops manual mode (when a button is held down or - released). + the device (like sending MQTT messages). """ - - # Mapping of ramp rates to human readable values - ramp_pretty = {0x00: 540, 0x01: 480, 0x02: 420, 0x03: 360, 0x04: 300, - 0x05: 270, 0x06: 240, 0x07: 210, 0x08: 180, 0x09: 150, - 0x0a: 120, 0x0b: 90, 0x0c: 60, 0x0d: 47, 0x0e: 43, 0x0f: 39, - 0x10: 34, 0x11: 32, 0x12: 30, 0x13: 28, 0x14: 26, - 0x15: 23.5, 0x16: 21.5, 0x17: 19, 0x18: 8.5, 0x19: 6.5, - 0x1a: 4.5, 0x1b: 2, 0x1c: .5, 0x1d: .3, 0x1e: .2, 0x1f: .1} - def __init__(self, protocol, modem, address, name=None): """Constructor @@ -58,183 +39,30 @@ def __init__(self, protocol, modem, address, name=None): """ super().__init__(protocol, modem, address, name) - # Current dimming level. 0x00 -> 0xff - self._level = 0x00 - - # Support dimmer style signals and motion on/off style signals. - # API: func(Device, int level, on_off.Mode mode, str reason) - self.signal_state = Signal() - - # Manual mode start up, down, off - # API: func(Device, on_off.Manual mode, str reason) - self.signal_manual = Signal() - - # Remote (mqtt) commands mapped to methods calls. Add to the base - # class defined commands. - self.cmd_map.update({ - 'on' : self.on, - 'off' : self.off, - 'increment_up' : self.increment_up, - 'increment_down' : self.increment_down, - 'set_flags' : self.set_flags, - }) - # Update the group map with the groups to be paired and the handler # for broadcast messages from this group self.group_map.update({0x01: self.handle_on_off}) #----------------------------------------------------------------------- - def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the device on. - - NOTE: This does NOT simulate a button press on the device - it just - changes the state of the device. It will not trigger any responders - that are linked to this device. To simulate a button press, call the - scene() method. - - This 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 - the state changed signals. + def cmd_on_values(self, mode, level, transition, group): + """Calculate Cmd Values for On Args: - group (int): The group to send the command to. For this device, - this must be 1. Allowing a group here gives us a consistent - API to the on command across devices. - level (int): If non zero, turn the device on. Should be in the - range 0 to 255. If None, use default on-level. mode (on_off.Mode): The type of command to send (normal, fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) + level (int): On level between 0-255. + transition (int): Ramp rate for the transition in seconds. + Returns + cmd1, cmd2 (int): Value of cmds for this device. """ - LOG.info("Dimmer %s cmd: on %s", self.addr, level) if transition or mode == on_off.Mode.RAMP: LOG.error("Device %s does not support transition.", self.addr) mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode if level is None: - # Not specified - choose brightness as pressing the button would do - if mode == on_off.Mode.FAST: - # Fast-ON command. Use full-brightness. - level = 0xff - else: - # Normal/instant ON command. Use default on-level. - # Check if we saved the default on-level in the device - # database when setting it. - level = self.get_on_level() - if self._level == level: - # Just like with button presses, if already at default on - # level, go to full brightness. - level = 0xff - assert level >= 0 and level <= 0xff - assert group == 0x01 - assert isinstance(mode, on_off.Mode) - - # Send the requested on code value. + # If level is not specified it uses the level that the device + # would go to if the button was physically pressed. + level = self.derive_on_level(mode) cmd1 = on_off.Mode.encode(True, mode) - msg = Msg.OutStandard.direct(self.addr, cmd1, level) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the device 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. - - 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 - the state changed signals. - - Args: - group (int): The group to send the command to. For this device, - this must be 1. Allowing a group here gives us a consistent - API to the on command across devices. - mode (on_off.Mode): The type of command to send (normal, fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Dimmer %s cmd: off", self.addr) - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode - assert group == 0x01 - assert isinstance(mode, on_off.Mode) - - # Send an off or instant off command. - cmd1 = on_off.Mode.encode(False, mode) - msg = Msg.OutStandard.direct(self.addr, cmd1, 0x00) - - # Use the standard command handler which will notify us when - # the command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def increment_up(self, reason="", on_done=None): - """Increment the current level up. - - Levels increment in units of 8 (32 divisions from off to on). - - 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 - the state changed signals. - - Args: - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Dimmer %s cmd: increment up", self.addr) - - msg = Msg.OutStandard.direct(self.addr, 0x15, 0x00) - - callback = functools.partial(self.handle_increment, delta=+8, - reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def increment_down(self, reason="", on_done=None): - """Increment the current level down. - - Levels increment in units of 8 (32 divisions from off to on). - - 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 - the state changed signals. - - Args: - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Dimmer %s cmd: increment down", self.addr) - - msg = Msg.OutStandard.direct(self.addr, 0x16, 0x00) - - callback = functools.partial(self.handle_increment, delta=-8, - reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) + return (cmd1, level) #----------------------------------------------------------------------- def link_data(self, is_controller, group, data=None): @@ -320,15 +148,8 @@ def link_data_from_pretty(self, is_controller, data): 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'] + data_1, data_2, data_3 = super().link_data_from_pretty(is_controller, + data) if not is_controller: if 'ramp_rate' in data: data_2 = 0x1f @@ -340,52 +161,6 @@ def link_data_from_pretty(self, is_controller, data): data_1 = int(data['on_level'] * 2.55 + .5) return [data_1, data_2, data_3] - #----------------------------------------------------------------------- - def set_backlight(self, level, on_done=None): - """Set the device backlight level. - - This changes the level of the LED back light that is used by the - device status LED's (dimmer levels, KeypadLinc buttons, etc). - - The default factory level is 0x1f. - - Per page 157 of insteon dev guide range is between 0x11 and 0x7F, - however in practice backlight can be incremented from 0x00 to at least - 0x7f. - - Args: - level (int): The backlight level in the range [0,255] - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - seq = CommandSeq(self, "Dimmer set backlight complete", on_done, - name="SetBacklight") - - # First set the backlight on or off depending on level value - is_on = level > 0 - LOG.info("Dimmer %s setting backlight to %s", self.label, is_on) - cmd = 0x09 if is_on else 0x08 - msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, bytes([0x00] * 14)) - msg_handler = handler.StandardCmd(msg, self.handle_backlight, on_done) - seq.add_msg(msg, msg_handler) - - if is_on: - # Second set the level only if on - LOG.info("Dimmer %s setting backlight to %s", self.label, level) - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x07, # D2 set global led brightness - level, # D3 brightness level - ] + [0x00] * 11) - - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - msg_handler = handler.StandardCmd(msg, self.handle_backlight, - on_done) - seq.add_msg(msg, msg_handler) - - seq.run() - #----------------------------------------------------------------------- def get_flags(self, on_done=None): """Hijack base get_flags to inject extended flags request. @@ -451,399 +226,3 @@ def handle_ext_flags(self, msg, on_done): on_done(True, "Operation complete", msg.data[5]) #----------------------------------------------------------------------- - def set_on_level(self, level, on_done=None): - """Set the device default on level. - - This changes the dimmer level the device will go to when the on - button is pressed. This can be very useful because a double-tap - (fast-on) will the turn the device to full brightness if needed. - - Args: - level (int): The default on level in the range [0,255] - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Dimmer %s setting on level to %s", self.label, level) - - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x06, # D2 set on level when button is pressed - level, # D3 brightness level - ] + [0x00] * 11) - - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_on_level, level=level) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def set_ramp_rate(self, rate, on_done=None): - """Set the device default ramp rate. - - This changes the dimmer default ramp rate of how quickly it will - turn on or off. This rate can be between 0.1 seconds and up to 9 - minutes. - - Args: - rate (float): Ramp rate in in the range [0.1, 540] seconds - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Dimmer %s setting ramp rate to %s", self.label, rate) - - data_3 = 0x1c # the default ramp rate is .5 - for ramp_key, ramp_value in self.ramp_pretty.items(): - if rate >= ramp_value: - data_3 = ramp_key - break - - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x05, # D2 set ramp rate when button is pressed - data_3, # D3 rate - ] + [0x00] * 11) - - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - msg_handler = handler.StandardCmd(msg, self.handle_ramp_rate, on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def set_flags(self, on_done, **kwargs): - """Set internal device flags. - - This command is used to change internal device flags and states. - Valid inputs are: - - - backlight=level: Change the backlight LED level (0-255). See - set_backlight() for details. - - - on_level=level: Change the default device on level (0-255) See - set_on_level for details. - - Args: - kwargs: Key=value pairs of the flags to change. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Dimmer %s cmd: set flags", self.label) - - # Check the input flags to make sure only ones we can understand were - # passed in. - FLAG_BACKLIGHT = "backlight" - FLAG_ON_LEVEL = "on_level" - FLAG_RAMP_RATE = "ramp_rate" - flags = set([FLAG_BACKLIGHT, FLAG_ON_LEVEL, FLAG_RAMP_RATE]) - unknown = set(kwargs.keys()).difference(flags) - if unknown: - LOG.error("Unknown Dimmer flags input: %s.\n Valid flags " - "are: %s", unknown, flags) - - # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self, "Dimmer set_flags complete", on_done, - name="SetFlags") - - if FLAG_BACKLIGHT in kwargs: - backlight = util.input_byte(kwargs, FLAG_BACKLIGHT) - seq.add(self.set_backlight, backlight) - - if FLAG_ON_LEVEL in kwargs: - on_level = util.input_byte(kwargs, FLAG_ON_LEVEL) - seq.add(self.set_on_level, on_level) - - if FLAG_RAMP_RATE in kwargs: - rate = util.input_float(kwargs, FLAG_RAMP_RATE) - seq.add(self.set_ramp_rate, rate) - - seq.run() - - #----------------------------------------------------------------------- - def handle_backlight(self, msg, on_done): - """Callback for handling set_backlight() responses. - - This is called when we get a response to the set_backlight() command. - We don't need to do anything - just call the on_done callback with - the status. - - Args: - msg (InpStandard): The response message from the command. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - on_done(True, "Backlight level updated", None) - - #----------------------------------------------------------------------- - def handle_on_level(self, msg, on_done, level): - """Callback for handling set_on_level() responses. - - This is called when we get a response to the set_on_level() command. - Update stored on-level in device DB and call the on_done callback with - the status. - - Args: - msg (InpStandard): The response message from the command. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - self.db.set_meta('on_level', level) - on_done(True, "Button on level updated", None) - - #----------------------------------------------------------------------- - def handle_ramp_rate(self, msg, on_done): - """Callback for handling set_ramp_rate() responses. - - This is called when we get a response to the set_ramp_rate() command. - We don't need to do anything - just call the on_done callback with - the status. - - Args: - msg (InpStandard): The response message from the command. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - if msg.flags.type == Msg.Flags.Type.DIRECT_ACK: - on_done(True, "Button ramp rate updated", None) - else: - on_done(False, "Button ramp rate failed", None) - - #----------------------------------------------------------------------- - def get_on_level(self): - """Look up previously-set on-level in device database, if present - - This is called when we need to look up what is the default on-level - (such as when getting an ON broadcast message from the device). - - If on_level is not found in the DB, assumes on-level is full-on. - """ - on_level = self.db.get_meta('on_level') - if on_level is None: - on_level = 0xff - return on_level - - #----------------------------------------------------------------------- - def handle_on_off(self, msg): - """Handle broadcast messages from this device. - - This is called from base.handle_broadcast using the group_map map. - - Args: - msg (InpStandard): Broadcast message from the device. - """ - # If we have a saved reason from a simulated scene command, use that. - # Otherwise the device button was pressed. - reason = self.broadcast_reason if self.broadcast_reason else \ - on_off.REASON_DEVICE - self.broadcast_reason = "" - - # ACK of the broadcast. Ignore this unless we sent a simulated off - # scene in which case run the broadcast done handler. This is a - # weird special case - see scene() for details. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("Dimmer %s broadcast ACK grp: %s", self.addr, msg.group) - return - - # On/off commands. - elif on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - LOG.info("Dimmer %s broadcast grp: %s on: %s mode: %s", self.addr, - msg.group, is_on, mode) - - # For an on command, we can update directly. - if is_on: - # Level isn't provided in the broadcast msg. - # What to use depends on which command was received. - if mode == on_off.Mode.FAST: - # Fast-ON command. Use full-brightness. - level = 0xff - else: - # Normal/instant ON command. Use default on-level. - # Check if we saved the default on-level in the device - # database when setting it. - level = self.get_on_level() - if self._level == level: - # Pressing on again when already at the default on - # level causes the device to go to full-brightness. - level = 0xff - self._set_level(level, mode, reason) - - else: - self._set_level(0x00, mode, reason) - - # Starting or stopping manual mode. - 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) - - self.signal_manual.emit(self, manual=manual, reason=reason) - - # Refresh to get the new level after the button is released. - 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 update their - # states since they will have seen the group broadcast and updated - # (without sending anything out). - self.update_linked_devices(msg) - - #----------------------------------------------------------------------- - def handle_refresh(self, msg): - """Callback for handling refresh() responses. - - This is called when we get a response to the refresh() command. The - refresh command reply will contain the current device state in cmd2 - and this updates the device with that value. It is called by - 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. - """ - LOG.ui("Dimmer %s refresh at level %s", self.addr, msg.cmd2) - - # Update the device dimmer level. - self._set_level(msg.cmd2, reason=on_off.REASON_REFRESH) - - #----------------------------------------------------------------------- - def handle_ack(self, msg, on_done, reason=""): - """Callback for standard commanded messages. - - This callback is run when we get a reply back from one of our - 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. - - 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) - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - """ - # If this it the ACK we're expecting, update the internal state and - # emit our signals. - LOG.debug("Dimmer %s ACK: %s", self.addr, msg) - - _is_on, mode = on_off.Mode.decode(msg.cmd1) - reason = reason if reason else on_off.REASON_COMMAND - self._set_level(msg.cmd2, mode, reason) - on_done(True, "Dimmer state updated to %s" % self._level, - msg.cmd2) - - #----------------------------------------------------------------------- - def handle_increment(self, msg, on_done, delta, reason=""): - """Callback for increment up/down commanded messages. - - This callback is run when we get a reply back from triggering an - increment up or down on the device. If the command was ACK'ed, we - know it worked. - - 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) - - delta (int): The amount +/- of level to change by. - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - """ - # If this it the ACK we're expecting, update the internal state and - # emit our signals. - LOG.debug("Dimmer %s ACK: %s", self.addr, msg) - - # Add the delta and bound at [0, 255] - level = min(self._level + delta, 255) - level = max(level, 0) - self._set_level(level, reason=reason) - - s = "Dimmer %s state updated to %s" % (self.addr, self._level) - on_done(True, s, msg.cmd2) - - #----------------------------------------------------------------------- - 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 - device that received the broadcast message (handle_broadcast) will - call this method for every device that is linked to it. The device - should look up the responder entry for the group in it's all link - database and update it's state accordingly. - - Args: - addr (Address): The device that sent the message. This is the - controller in the scene. - msg (InpStandard): 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, msg.group, is_controller=False) - if not entry: - LOG.error("Dimmer %s has no group %s entry from %s", self.addr, - msg.group, addr) - return - - reason = on_off.REASON_SCENE - - # Handle on/off commands codes. - if on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - - # Get the on level from the database entry. - level = entry.data[0] if is_on else 0x00 - self._set_level(level, mode, reason=reason) - - # Increment up 1 unit which is 8 levels. - elif msg.cmd1 == 0x15: - self._set_level(min(0xff, self._level + 8), reason=reason) - - # Increment down 1 unit which is 8 levels. - elif msg.cmd1 == 0x16: - self._set_level(max(0x00, self._level - 8), reason=reason) - - # Starting or stopping manual mode. - elif on_off.Manual.is_valid(msg.cmd1): - manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) - self.signal_manual.emit(self, manual=manual, reason=reason) - - # If the button is released, refresh to get the final level. - if manual == on_off.Manual.STOP: - self.refresh() - - else: - LOG.warning("Dimmer %s unknown group cmd %#04x", self.addr, - msg.cmd1) - - #----------------------------------------------------------------------- - def _set_level(self, level, mode=on_off.Mode.NORMAL, reason=""): - """Update the device level 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. - - Args: - level (int): The new device level in the range [0,255]. 0 is off. - mode (on_off.Mode): The type of on/off that was triggered (normal, - fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - """ - LOG.info("Setting device %s on=%s %s %s", self.label, level, mode, - reason) - self._level = level - - self.signal_state.emit(self, level=level, mode=mode, reason=reason) - - #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/EZIO4O.py b/insteon_mqtt/device/EZIO4O.py index 383e611f..94b37d35 100644 --- a/insteon_mqtt/device/EZIO4O.py +++ b/insteon_mqtt/device/EZIO4O.py @@ -4,14 +4,12 @@ # #=========================================================================== import functools -from .Base import Base -from . import functions +from .base import ResponderBase 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 LOG = log.get_logger() @@ -58,7 +56,7 @@ } -class EZIO4O(functions.Set, Base): +class EZIO4O(ResponderBase): """Smartenit EZIO4O - 4 relay output device. This class can be used to model the EZIO4O device which has 4 outputs. @@ -67,12 +65,7 @@ class EZIO4O(functions.Set, Base): 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_state( Device, int group, bool is_on, on_off.Mode mode, str - reason ): Sent whenever an output is turned on or off. - Group will be 1 to 4 matching the corresponding device - output. + the device (like sending MQTT messages). """ def __init__(self, protocol, modem, address, name=None): @@ -90,21 +83,6 @@ def __init__(self, protocol, modem, address, name=None): self._is_on = [False, False, False, False] # output state - # Support on/off style signals. - # API: func(Device, int group, bool is_on, on_off.Mode mode, - # str reason) - self.signal_state = Signal() - - # 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_flags": self.set_flags, - } - ) - # EZIOxx configuration port settings. See set_flags(). self._flag_value = None @@ -120,9 +98,14 @@ def __init__(self, protocol, modem, address, name=None): # The EZIO4O has no inputs and so has no groups to pair to or # broadcast messages to process # self.group_map.update({}) + self.responder_groups = [1, 2, 3, 4] + + # Define the flags handled by set_flags() + for flag in EZIO4xx_flags: + self.set_flags_map[flag] = self._change_flags #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): + def refresh(self, force=False, group=None, on_done=None): """Refresh the current device state and database if needed. This sends a ping to the device. The reply has the current device @@ -133,10 +116,15 @@ 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). + EZIO4O uses a completely different refresh command than standard. + 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 device model information even if it is already known. + group (int): The group being refreshed, it is passed to + handle_refresh() so that the state signal is correct. Should + generally be None. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ @@ -167,87 +155,87 @@ def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", transition=None, on_done=None): """Turn the device on. - 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 - the state changed signals. + This is a wrapper around the SetAndState functions class. Args: - group (int): The group to send the command to. Group 1 to 4 - matching output 1 to 4. - level (int): If non zero, turn the device on. Should be in the - range 0 to 255. Only dimmers use the intermediate values, all - other devices look at level=0 or level>0. + group (int): The group to send the command to. + level (int): If non-zero, turn the device on. The API is an int + to keep a consistent API with other devices. mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Transition time in seconds if supported. reason (str): This is optional and is used to identify why the command was sent. It is passed through to the output signal when the state changes - nothing else is done with it. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("EZIO4O %s grp: %s cmd: on", self.label, group) - assert 1 <= group <= 4 - assert isinstance(mode, on_off.Mode) - - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode - - # Use a standard message to send "output on" (0x45) command for the - # output - msg = Msg.OutStandard.direct(self.addr, 0x45, group - 1) - - # Use the standard command handler which will notify us when - # the command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - # See __init__ code comments for what this is for. self._which_output.append(group) - - # Send the message to the PLM modem for protocol. - self.send(msg, msg_handler) + super().on(group=group, level=level, mode=mode, reason=reason, + transition=transition, on_done=on_done) #----------------------------------------------------------------------- def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", transition=None, on_done=None): - """Turn the device off. + """Turn the device on. - 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 - the state changed signals. + This is a wrapper around the SetAndState functions class. Args: - group (int): The group to send the command to. Group 1 to 4 - matching output 1 to 4. + group (int): The group to send the command to. mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Transition time in seconds if supported. reason (str): This is optional and is used to identify why the command was sent. It is passed through to the output signal when the state changes - nothing else is done with it. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("EZIO4O %s grp: %s cmd: off", self.label, group) - assert 1 <= group <= 4 - assert isinstance(mode, on_off.Mode) + # See __init__ code comments for what this is for. + self._which_output.append(group) + super().off(group=group, mode=mode, reason=reason, + transition=transition, on_done=on_done) + + #----------------------------------------------------------------------- + def cmd_on_values(self, mode, level, transition, group): + """Calculate Cmd Values for On + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + level (int): On level between 0-255. + transition (int): Ramp rate for the transition in seconds. + group (int): The group number that this state applies to. Defaults + to None. + Returns + cmd1, cmd2 (int): Value of cmds for this device. + """ if transition or mode == on_off.Mode.RAMP: LOG.error("Device %s does not support transition.", self.addr) mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode + if level: + LOG.error("Device %s does not support level.", self.addr) + cmd1 = 0x45 + cmd2 = group - 1 + return (cmd1, cmd2) - # Use a standard message to send "output off" (0x46) command for the - # output - msg = Msg.OutStandard.direct(self.addr, 0x46, group - 1) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - - # See __init__ code comments for what this is for. - self._which_output.append(group) + #----------------------------------------------------------------------- + def cmd_off_values(self, mode, transition, group): + """Calculate Cmd Values for Off - # Send the message to the PLM modem for protocol. - self.send(msg, msg_handler) + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Ramp rate for the transition in seconds. + group (int): The group number that this state applies to. Defaults + to None. + Returns + cmd1, cmd2 (int): Value of cmds for this device. + """ + if transition or mode == on_off.Mode.RAMP: + LOG.error("Device %s does not support transition.", self.addr) + mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode + cmd1 = 0x46 + cmd2 = group - 1 + return (cmd1, cmd2) #----------------------------------------------------------------------- def link_data(self, is_controller, group, data=None): @@ -330,15 +318,8 @@ def link_data_from_pretty(self, is_controller, data): 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"] + data_1, data_2, data_3 = super().link_data_from_pretty(is_controller, + data) if "group" in data: if is_controller: data_3 = data["group"] @@ -347,7 +328,7 @@ def link_data_from_pretty(self, is_controller, data): return [data_1, data_2, data_3] #----------------------------------------------------------------------- - def set_flags(self, on_done, **kwargs): + def _change_flags(self, on_done, **kwargs): """Set internal device flags. This command is used to change EZIOxx Configuration Port settings. @@ -382,8 +363,6 @@ def set_flags(self, on_done, **kwargs): on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("EZIO4O %s cmd: set flags", self.label) - # TODO initialize flags on first run # Initialise flag value by reading the device Configuration Port # settings @@ -394,20 +373,9 @@ def set_flags(self, on_done, **kwargs): ) return - # Check the input flags to make sure only ones we can understand were - # passed in. - valid_flags = EZIO4xx_flags.keys() - flags_to_set = kwargs.keys() - - unknown = set(flags_to_set).difference(valid_flags) - if unknown: - LOG.error( - "EZIO4O Unknown flags input: %s.\n Valid " - "flags are: %s", unknown, valid_flags - ) - # Construct the flag register to write new_flag_value = self._flag_value + flags_to_set = kwargs.keys() for field in list(flags_to_set): if True in EZIO4xx_flags[field]["options"]: @@ -513,7 +481,7 @@ def handle_flags(self, msg, on_done): on_done(False, "EZIO4O %s flags update failed" % self.label, None) #----------------------------------------------------------------------- - def handle_refresh(self, msg): + def handle_refresh(self, msg, group=None): """Callback for handling refresh() responses. This is called when we get a response to the refresh() command. The @@ -533,32 +501,33 @@ def handle_refresh(self, msg): # State change for output if is_on != self._is_on[i]: - self._set_is_on(i + 1, is_on, reason=on_off.REASON_REFRESH) + self._set_state(group=i + 1, is_on=is_on, + reason=on_off.REASON_REFRESH) else: LOG.error("EZIO4O %s unknown refresh response %s", self.label, msg) #----------------------------------------------------------------------- - def handle_ack(self, msg, on_done, reason=""): + def decode_on_level(self, cmd1, cmd2): """Callback for standard commanded messages. - This callback is run when we get a reply back from one of our - 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. + Decodes the cmds recevied from the device into is_on, level, and mode + to be consumed by _set_state(). 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) - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. + cmd1 (byte): The command 1 value + cmd2 (byte): The command 2 value + Returns: + is_on (bool): Is the device on. + mode (on_off.Mode): The type of command to send (normal, fast, etc). + level (int): On level between 0-255. + group (int): The group number that this state applies to. Defaults + to None. """ - assert 0x00 <= msg.cmd2 <= 0x0F - assert msg.cmd1 in [0x45, 0x46] - - LOG.debug("EZIO4O %s ACK response %s", self.label, msg) + # Default Returns + group = None + is_on = None + level = None + mode = on_off.Mode.NORMAL # Get the last output we were commanding. The message doesn't tell # us which output it was so we have to track it here. See __init__ @@ -566,99 +535,50 @@ def handle_ack(self, msg, on_done, reason=""): if not self._which_output: LOG.error("EZIO4O %s ACK error. No output ID's were saved", self.label) - on_done(False, "EZIO4O update failed - no ID's saved", None) - return - - group = self._which_output.pop(0) - - # If this it the ACK we're expecting, update the internal - # state and emit our signals. - if msg.flags.type == Msg.Flags.Type.DIRECT_ACK: - LOG.debug("EZIO4O %s ACK: %s", self.label, msg) + else: + group = self._which_output.pop(0) + # Update other groups as reason=refresh for i in range(4): - is_on = bool(util.bit_get(msg.cmd2, i)) - + if i == group - 1: + continue + is_on = bool(util.bit_get(cmd2, i)) # State change for the output and all outputs with state change - if is_on != self._is_on[i] or i == group - 1: - self._set_is_on(i + 1, is_on, reason=on_off.REASON_REFRESH) - on_done(True, "EZIO4O state %s updated to: %s" % - (i + 1, is_on), None) + if is_on != self._is_on[i]: + self._set_state(group=i + 1, is_on=is_on, + reason=on_off.REASON_REFRESH) + # Update the requested group as part of normal set process + is_on = bool(util.bit_get(cmd2, group)) - elif msg.flags.type == Msg.Flags.Type.DIRECT_NAK: - LOG.error("EZIO4O %s NAK error: %s", self.label, msg) - on_done(False, "EZIO4O state %s update failed" % group, None) + return (is_on, level, mode, group) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, msg): - """Respond to a group command for this device. + def group_cmd_local_group(self, entry): + """Get the Local Group Affected by this Group Command - This is called when this device is a responder to a scene. The - device that received the broadcast message (handle_broadcast) will - call this method for every device that is linked to it. The device - should look up the responder entry for the group in it's all link - database and update it's state accordingly. + For most devices this is group 1, but for multigroup devices such + as the KPL, they may need to decode the local group from the + entry data. Args: - addr (Address): The device that sent the message. This is the - controller in the scene. - msg (InpStandard): Broadcast message from the device. Use - msg.group to find the group and msg.cmd1 for the command. + entry (DeviceEntry): The local db entry for this group command. + Returns: + group (int): The local group affected """ - - # Make sure we're really a responder to this message. This shouldn't - # ever occur. - entry = self.db.find(addr, msg.group, is_controller=False) - if not entry: - LOG.error( - "EZIO4O %s has no group %s entry from %s", - self.label, msg.group, addr - ) - return - - # The local button being modified is stored in the db entry. - localGroup = entry.data[2] + 1 - - # Handle on/off commands codes. - if on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - self._set_is_on(localGroup, is_on, mode, on_off.REASON_SCENE) - - else: - LOG.warning("EZIO4O %s unknown group cmd %#04x", self.label, - msg.cmd1) + return entry.data[2] + 1 #----------------------------------------------------------------------- - def _set_is_on(self, group, is_on, mode=on_off.Mode.NORMAL, reason=""): - """Update the device on/off state. + def _cache_state(self, group, is_on, level, reason): + """Cache the State of the Device - 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. + Used to help with the EZIO unique functions. Args: - group (int): The group to update (1 to 4). - is_on (bool): True if the switch is on, False if it isn't. - mode (on_off.Mode): The type of on/off that was triggered (normal, - fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. + group (int): The group which this applies + is_on (bool): Whether the device is on. + level (int): The new device level in the range [0,255]. 0 is off. + reason (str): Reason string to pass around. """ - is_on = bool(is_on) - - LOG.info( - "EZIO4O %s setting grp: %s to %s %s %s", - self.label, - group, - is_on, - mode, - reason, - ) self._is_on[group - 1] = is_on - # Notify others that the output state has changed. - self.signal_state.emit(self, button=group, is_on=is_on, mode=mode, - reason=reason) - #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/FanLinc.py b/insteon_mqtt/device/FanLinc.py index 2bc0bc2c..36971a1b 100644 --- a/insteon_mqtt/device/FanLinc.py +++ b/insteon_mqtt/device/FanLinc.py @@ -6,7 +6,6 @@ import enum import functools from .Dimmer import Dimmer -from ..CommandSeq import CommandSeq from .. import handler from .. import log from .. import message as Msg @@ -83,44 +82,28 @@ def __init__(self, protocol, modem, address, name=None): self.group_map = {} #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): - """Refresh the current device state and database if needed. + def addRefreshData(self, seq, force=False): + """Add commands to refresh any internal data required. - This sends a ping to the device. The reply has the current device - state (on/off, level, etc) and the current db delta value which is - checked against the current db value. If the current db is out of - date, it will trigger a download of the database. - - This will send out an updated signal for the current device status - whenever possible. + 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 + database delta field. Pass skip_db here - we'll let the + refresh handler take care of getting the database updated. + Otherwise this handler and the one created in the Base class refresh + would download the database twice. Args: + seq (CommandSeq): The command sequence to add the command to. 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 device model information even if it is already known. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) """ - LOG.info("Device %s cmd: fan status refresh", self.addr) - - seq = CommandSeq(self, "Refresh complete", on_done, name="DevRefresh") - - # 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 - # database delta field. Pass skip_db here - we'll let the dimmer - # refresh handler above take care of getting the database updated. - # Otherwise this handler and the one created in the dimmer refresh - # would download the database twice. msg = Msg.OutStandard.direct(self.addr, 0x19, 0x03) msg_handler = handler.DeviceRefresh(self, self.handle_refresh_fan, force=False, num_retry=3, skip_db=True) seq.add_msg(msg, msg_handler) - - # If we get the FAN state correctly, then have the dimmer also get - # it's state and update the database if necessary. - seq.add(Dimmer.refresh, self, force) - seq.run() + super().addRefreshData(seq, force=force) #----------------------------------------------------------------------- def fan_on(self, speed=None, reason="", on_done=None): @@ -391,8 +374,8 @@ def link_data_to_pretty(self, is_controller, data): {'group': data[2]}] if data[2] <= 0x01: ramp = 0x1f # default - if data[1] in Dimmer.ramp_pretty: - ramp = Dimmer.ramp_pretty[data[1]] + if data[1] in self.ramp_pretty: + ramp = self.ramp_pretty[data[1]] ret = [{'on_level': int((data[0] / .255) + .5) / 10}, {'ramp_rate': ramp}, {'group': data[2]}] @@ -413,21 +396,14 @@ def link_data_from_pretty(self, is_controller, data): 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'] + data_1, data_2, data_3 = super().link_data_from_pretty(is_controller, + data) if not is_controller: if 'group' in data: data_3 = data['group'] if 'ramp_rate' in data and (data_3 is None or data_3 <= 0x01): data_2 = 0x1f - for ramp_key, ramp_value in Dimmer.ramp_pretty.items(): + for ramp_key, ramp_value in self.ramp_pretty.items(): if data['ramp_rate'] >= ramp_value: data_2 = ramp_key break diff --git a/insteon_mqtt/device/HiddenDoor.py b/insteon_mqtt/device/HiddenDoor.py new file mode 100644 index 00000000..aeadaf94 --- /dev/null +++ b/insteon_mqtt/device/HiddenDoor.py @@ -0,0 +1,644 @@ +#=========================================================================== +# +# Insteon battery powered hidden door sensor +# +#=========================================================================== +import functools +import time +from .BatterySensor import BatterySensor +from .. import log +from .. import handler +from ..Signal import Signal +from .. import message as Msg +from .. import on_off +from .. import util + +LOG = log.get_logger() + + +class HiddenDoor(BatterySensor): + """Insteon battery powered hidden sensor. + + A hidden door sensor is basically an on/off sensor except that it's + battery powered and only awake for a short time after open or closed is + detected or the set button is pressed. + + It can be configured in "one group" mode or "two group" mode: + + In one group mode the open is broadcast as on ON command to group 0x01 + and closed state is broadcast as an OFF command to group 0x01 + + In two group mode, open is broadcast as an ON command to group 0x01 and + closed is broadcast as on ON command to group 0x02 + + This was done to allow direct automation links from this device to + operate different devices for open and closed. + + The hidden door sensor also supports low battery on group 0x03. This + is sent when the battery level falls below a configurable low battery + level. + + This sensor will also broadcast a heartbeat signal on group 4. The + default interval for heartbeat is 24 hours but it is configurable in 5 + minute increments from 5 mins to 24 hours. + + 5 mins x 0x00->0xff + + Details on this device are in the insteon developers notes here: + + http://cache.insteon.com/developer/2845-222dev-102013-en.pdf + + 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 + manually and then quickly send an MQTT command with the payload 'getdb' + to download the database. We also can't test to see if the local + database is current or what the current open/closed state is - we can + really only respond to the sensor when it sends out a message. + + The device will broadcast messages on the following groups: + group 01 = Open [One or Two Group Mode] (0x11) / Closed [One Group + Mode Only] (0x13) + group 02 = Closed [Two Group Mode Only] (0x11) + group 03 = Low battery (0x11) / Good battery (0x13) + group 04 = Heartbeat (0x11) + + 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 ): Sent when the sensor is tripped + (is_on=True) or resets (is_on=False). + + - signal_low_battery( Device, bool is_low ): Sent to indicate the current + battery state. + + - signal_heartbeat( Device, True ): Sent when the device has broadcast a + heartbeat signal. + """ + type_name = "hidden_door" + + # This defines what is the minimum time between battery status requests + # for devices that support it. Value is in seconds + # Currently set at 3 hours + BATTERY_TIME = (60 * 60) * 3 + + def __init__(self, protocol, modem, address, name=None): + """Constructor + + Args: + protocol (Protocol): The Protocol object used to communicate + with the Insteon network. This is needed to allow the + device to send messages to the PLM modem. + modem (Modem): The Insteon modem used to find other devices. + address (Address): The address of the device. + name (str): Nice alias name to use for the device. + """ + super().__init__(protocol, modem, address, name) + + self.signal_voltage = Signal() + + # Insert the 'Two Groups' closed callback on group 02. Base class + # already handles the other groups. + self.group_map.update({0x02 : self.handle_closed}) + + # Remote (mqtt) commands mapped to methods calls. Add to the + # base class defined commands. + self.cmd_map.update({ + 'set_heart_beat_interval': self.set_heart_beat_interval, + 'set_low_battery_voltage': self.set_low_battery_voltage, + 'get_battery_voltage' : self.get_flags, + }) + + # This allows for a short timer between sending automatic battery + # requests. Otherwise, a request may get queued multiple times + self._battery_request_time = 0 + + # Define the flags handled by set_flags() + # Keys are the flag names in lower case. The value should be the + # function to call. The signature of the function is + # function(on_done=None, **kwargs). Each function will receive all + # flags specified in the call and should just ignore those that are + # unrelated. If the value None is used, no function will be called if + # that key is the only one passed. Functions will only be called once + # even if the same function is used for multiple flags + self.set_flags_map = {"cleanup_report": self._set_cleanup_report, + "led_disable": self._set_led_disable, + "link_to_all": self._set_link_to_all, + "two_groups": self._set_two_groups, + "prog_lock": self._set_prog_lock, + "repeat_closed": self._set_repeat_closed, + "repeat_open": self._set_repeat_open, + "stay_awake": self._set_stay_awake} + + #----------------------------------------------------------------------- + @property + def battery_voltage_time(self): + """Returns the timestamp of the last battery voltage report from the + saved metadata + """ + ret = self.db.get_meta('battery_voltage_time') + if ret is None: + ret = 0 + return ret + + #----------------------------------------------------------------------- + @battery_voltage_time.setter + def battery_voltage_time(self, val): + """Saves the timestamp of the last battery voltage report to the + database metadata + Args: + val: (timestamp) time.time() value + """ + self.db.set_meta('battery_voltage_time', val) + + #----------------------------------------------------------------------- + def set_low_battery_voltage(self, on_done, voltage=None): + """Set low voltage value. + + Called from the mqtt command functions or cmd_line + + Args: + voltage: (int) The low voltage value + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + voltage = util.input_byte({'voltage': voltage}, 'voltage') + if voltage is not None: + LOG.info("Hidden Door %s cmd: set low voltage= %s", self.label, + voltage) + on_done(True, "Low voltage set.", None) + else: + LOG.warning("Hidden Door %s set_low_voltage cmd requires voltage \ + key.", self.label) + on_done(False, "Low voltage not specified.", None) + return + + # Extended message data - see hidden door dev guide page 10 + data = bytes([ + 0x01, # D1 must be group 0x01 + 0x03, # D2 set low bat voltage + voltage, # D3 voltage + ] + [0x00] * 11) + + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) + + # Use the standard command handler which will notify us when the + # command is ACK'ed. + callback = functools.partial(self.handle_low_battery_voltage, + voltage=voltage) + msg_handler = handler.StandardCmd(msg, callback, on_done) + + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def set_heart_beat_interval(self, on_done, interval=None): + """Set heart beat interval. + + Called from the mqtt command functions or cmd_line + + Args: + voltage: (float) The low voltage value + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + interval = util.input_byte({'interval': interval}, 'interval') + if interval is not None: + LOG.info("Hidden Door %s cmd: set heart beat interval= %s", + self.label, interval) + on_done(True, "heart Beat Interval set.", None) + else: + LOG.warning("Hidden Door %s heart_beat_interval cmd requires \ + interval key.", self.label) + on_done(False, "Interval not specified.", None) + return + + # Extended message data - see hidden door dev guide page 10 + data = bytes([ + 0x01, # D1 must be group 0x01 + 0x02, # D2 set heart beat interval + interval, # D3 interval + ] + [0x00] * 11) + + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) + + # Use the standard command handler which will notify us when the + # command is ACK'ed. + callback = functools.partial(self.handle_heart_beat_interval, + interval=interval) + msg_handler = handler.StandardCmd(msg, callback, on_done) + + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def handle_closed(self, msg): + """Handle sensor activation. + + This is called by the device when a group broadcast on group 02 is + sent out by the sensor. + + This is necessary because an ON command on this group actually means + that this device is off or closed. Otherwise this is copied from + Base.handle_on_off() + + Args: + msg (InpStandard): Broadcast message from the device. + """ + # If we have a saved reason from a simulated scene command, use that. + # Otherwise the device button was pressed. + reason = self.broadcast_reason if self.broadcast_reason else \ + on_off.REASON_DEVICE + self.broadcast_reason = "" + + # On/off command codes. + if on_off.Mode.is_valid(msg.cmd1): + is_on, mode = on_off.Mode.decode(msg.cmd1) + LOG.info("Device %s broadcast grp: %s on: %s mode: %s", self.addr, + msg.group, is_on, mode) + + if is_on: + # Note is_on value is inverted in call to _set_state + level = self.derive_on_level(mode) + self._set_state(is_on=False, level=level, mode=mode, + group=msg.group, reason=reason) + else: + level = self.derive_off_level(mode) + self._set_state(is_on=True, level=level, mode=mode, + group=msg.group, reason=reason) + + # 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). + self.update_linked_devices(msg) + + #----------------------------------------------------------------------- + def get_flags(self, on_done=None): + """Get the Insteon operational extended flags field from the device. + + For the hidden door device, these flags include cleanup_report, + led_disable, link_to_all, two_groups, prog_lock, repeat_closed, + repeat_open, as well as the battery voltage, heart beat interval, + low battery level + + Args: + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("Hidden Door %s cmd: get extended operation flags", + self.label) + + # Requesting data is all 0s. Flags are in D3 of ext response msg + data = bytes([0x00] * 14) + + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) + msg_handler = handler.ExtendedCmdResponse(msg, self.handle_ext_flags, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def handle_ext_flags(self, msg, on_done): + """Handle replies to the get_flags command. + + Data 3 contains the operating flags + Data 4 contains the battery voltage + Data 5 open/closed status + Data 6 heart beat interval + Data 7 low battery level + + Args: + msg (message.InpExtended): The message reply. The current + flags are in D2. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.ui("Hidden door sensor %s extended operating flags: %s", self.addr, + "{:08b}".format(msg.data[2])) + + # Decode and display the operating flags + rawflags = msg.data[2] + LOG.ui("Hidden door sensor %s Configuration:", self.addr) + # Bit 0 + if rawflags & 0b00000001 == 1: + LOG.ui("\tSend Cleanup Report") + else: + LOG.ui("\tDon't Send Cleanup Report") + # Bit 1 + if rawflags & 0b00000010 == 2: + LOG.ui("\tSend Open on Group 1 ON and Closed on Group 2 ON") + else: + LOG.ui("\tSend both Open and Closed on Group 1 [On=Open and " + "Off=Closed]") + # Bit 2 + if rawflags & 0b00000100 == 4: + LOG.ui("\tSend Repeated Open Commands [Every 5 mins for 50 mins]") + else: + LOG.ui("\tDon't Send Repeated Open Commands") + # Bit 3 + if rawflags & 0b00001000 == 8: + LOG.ui("\tSend Repeated Closed Commands [Every 5 mins for 50 " + "mins]") + else: + LOG.ui("\tDon't Send Repeated Closed Commands") + # Bit 4 + if rawflags & 0b00010000 == 16: + LOG.ui("\tLink to FF Group") + else: + LOG.ui("\tDon't link to FF Group") + # Bit 5 + if rawflags & 0b00100000 == 32: + LOG.ui("\tLED does not blink on transmission") + else: + LOG.ui("\tLED blinks on transmission") + # Bit 6 + if rawflags & 0b01000000 == 64: + LOG.ui("\tNo Effect") + else: + LOG.ui("\tNo Effect") + # Bit 7 + if rawflags & 0b10000000 == 128: + LOG.ui("\tProgramming lock on") + else: + LOG.ui("\tProgramming lock off") + + # D6 Heart Beat Interval + # 5 minute increments for 1-255. 0 = 24 hours (default) + hb_interval = msg.data[5] + if hb_interval > 0: + hb_interval_minutes = hb_interval * 5 + else: + hb_interval_minutes = 24 * 60 # convert 24 hours to minutes + LOG.ui("\tHeart beat interval raw level is %s or %s minutes ", + hb_interval, hb_interval_minutes) + + # D7 Low Battery Level + lb_level = msg.data[6] + LOG.ui("\tLow battery level is %s", lb_level) + + # D4 Current Battery Level + batt_volt = msg.data[3] + LOG.ui("Hidden door sensor %s Battery voltage is %s", self.addr, + batt_volt) + + self.battery_voltage_time = time.time() + + self.signal_voltage.emit(self, batt_volt) + + on_done(True, "Operation complete", msg.data[5]) + + #----------------------------------------------------------------------- + def handle_ext_cmd(self, msg, on_done): + """Handle replies to the set_flags command. + Nothing to do, any NAK of failure is caught by the message handler + """ + on_done(True, "Operation complete", None) + + #----------------------------------------------------------------------- + def handle_low_battery_voltage(self, msg, on_done, voltage): + """Callback for handling set_low_battery_voltage() responses. + + This is called when we get a response to the + set_low_battery_voltage() command. Update stored low bat voltage in + device DB and call the on_done callback with the status. + + Args: + msg (InpStandard): The response message from the command. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + on_done(True, "Low Battery Voltage", None) + + #----------------------------------------------------------------------- + def handle_heart_beat_interval(self, msg, on_done, interval): + """Callback for handling set_heart_beat_interval() responses. + + This is called when we get a response to the + set_heart_beat_interval() command. Update stored heart beat interval + in device DB and call the on_done callback with the status. + + Args: + msg (InpStandard): The response message from the command. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + on_done(True, "Heart Beat Interval", None) + + #----------------------------------------------------------------------- + def _set_cleanup_report(self, on_done=None, **kwargs): + """Set clean up report on + + - cleanup_report = 1/0: tell the device whether or not to send + cleanup reports + """ + # Check for valid input + cleanup_report = util.input_bool(kwargs, 'cleanup_report') + if cleanup_report is None: + on_done(False, 'Invalid cleanup_report flag.', None) + return + + # The dev guide says 0x17 for cleanup report on and 0x16 for off + cmd = 0x17 if cleanup_report else 0x16 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def _set_led_disable(self, on_done=None, **kwargs): + """Set LED Disable - LED near back of device will light momentarily + when changing state + + - led_disable = 1/0: disables small led on back of device to blink + on state change + + """ + # Check for valid input + led_disable = util.input_bool(kwargs, 'led_disable') + if led_disable is None: + on_done(False, 'Invalid led_disable flag.', None) + return + + # The dev guide says 0x02 for LED disable and 0x03 for enable + cmd = 0x02 if led_disable else 0x03 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def _set_link_to_all(self, on_done=None, **kwargs): + """Set Link to all - This creates a link to the 0xFF group, which is + is the same as link to your modem on groups + 0x01, 0x02, 0x03, 0x04 + + - link_to_all = 1/0: links to 0xFF group (all available groups) + """ + # Check for valid input + link_to_all = util.input_bool(kwargs, 'link_to_all') + if link_to_all is None: + on_done(False, 'Invalid link_to_all flag.', None) + return + + # The dev guide says 0x06 for link to all and 0x07 for link to one + cmd = 0x06 if link_to_all else 0x07 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def _set_prog_lock(self, on_done=None, **kwargs): + """Set local programming lock + + - prog_lock = 1/0: prevents device from being programmed by local + button presses + """ + # Check for valid input + prog_lock = util.input_bool(kwargs, 'prog_lock') + if prog_lock is None: + on_done(False, 'Invalid prog_lock flag.', None) + return + + # The dev guide says 0x00 for locl on and 0x01 for lock off + cmd = 0x00 if prog_lock else 0x01 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def _set_repeat_closed(self, on_done=None, **kwargs): + """Set Repeat Closed - This sets the device to send repeated closed + messages every 5 mins for 50 mins + + - repeat_closed = 1/0: Repeat open command every 5 mins for 50 mins + """ + # Check for valid input + repeat_closed = util.input_bool(kwargs, 'repeat_closed') + if repeat_closed is None: + on_done(False, 'Invalid repeat_closed flag.', None) + return + + # The dev guide says 0x08 for repeat closed and 0x09 for don't + # repeat closed + cmd = 0x08 if repeat_closed else 0x09 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def _set_repeat_open(self, on_done=None, **kwargs): + """Set Repeat Open - This sets the device to send repeated open + messages every 5 mins for 50 mins + + - repeat_open = 1/0: Repeat open command every 5 mins for 50 mins + """ + # Check for valid input + repeat_open = util.input_bool(kwargs, 'repeat_open') + if repeat_open is None: + on_done(False, 'Invalid repeat_open flag.', None) + return + + # The dev guide says 0x0A for repeat open and 0x0B for don't + # repeat open + cmd = 0x0a if repeat_open else 0x0b + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def _set_stay_awake(self, on_done=None, **kwargs): + """Set Stay Awake - Do not go to sleep + + - stay_awake = 1/0: keeps device awake - but uses a lot of battery + """ + # Check for valid input + stay_awake = util.input_bool(kwargs, 'stay_awake') + if stay_awake is None: + on_done(False, 'Invalid stay_awake flag.', None) + return + + # The dev guide says 0x0A for repeat open and 0x0B for don't + # repeat open + cmd = 0x18 if stay_awake else 0x19 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def _set_two_groups(self, on_done=None, **kwargs): + """Set Two Groups + + - two_groups = 1/0: Report open/close on group 1 or report open on + group 1 and closed on 2 + """ + # Check for valid input + two_groups = util.input_bool(kwargs, 'two_groups') + if two_groups is None: + on_done(False, 'Invalid two_groups flag.', None) + return + + # The dev guide says 0x04 sets two groups and 0x05 sets one group + cmd = 0x04 if two_groups else 0x05 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, + bytes([0x00] * 14)) + + msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def auto_check_battery(self): + """Queues a Battery Voltage Request if Necessary + + If the requisite amount of time has elapsed, queue a battery request. + """ + + # This is a device that supports battery requests + last_checked = self.battery_voltage_time + # Don't send this message more than once every 5 minutes no + # matter what + if (last_checked + self.BATTERY_TIME <= time.time() and + self._battery_request_time + 300 <= time.time()): + self._battery_request_time = time.time() + LOG.info("Hidden Door %s: Auto requesting battery voltage", + self.label) + self.get_flags() + + #----------------------------------------------------------------------- + def awake(self, on_done): + """Injects a Battery Voltage Request if Necessary + + Queue a battery request that should go out now, since the device is + awake. + """ + self.auto_check_battery() + super().awake(on_done) + + #----------------------------------------------------------------------- + def _pop_send_queue(self): + """Injects a Battery Voltage Request if Necessary + + Queue a battery request that should go out now, since the device is + awake. + """ + self.auto_check_battery() + super()._pop_send_queue() + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/IOLinc.py b/insteon_mqtt/device/IOLinc.py index b7a69452..45be6883 100644 --- a/insteon_mqtt/device/IOLinc.py +++ b/insteon_mqtt/device/IOLinc.py @@ -5,20 +5,23 @@ #=========================================================================== import enum import time -from .Base import Base -from . import functions +import functools +from .base import ResponderBase 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 LOG = log.get_logger() +#Constants for easier comprehension +GROUP_SENSOR = 1 +GROUP_RELAY = 2 -class IOLinc(functions.Set, Base): + +class IOLinc(ResponderBase): """Insteon IOLinc relay/sensor device. This class can be used to model the IOLinc device which has a sensor and @@ -37,11 +40,7 @@ class IOLinc(functions.Set, Base): 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 sensor_is_on, bool relay_is_on, - on_off.Mode mode ): - Sent whenever the sensor is turned on or off. + the device (like sending MQTT messages). NOTES: - Broadcast messages from the device always* describe the state of the @@ -135,22 +134,30 @@ def __init__(self, protocol, modem, address, name=None): # 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() - - # 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_flags' : self.set_flags, - }) - # Update the group map with the groups to be paired and the handler # for broadcast messages from this group self.group_map.update({0x01: self.handle_on_off}) + # Define the flags handled by set_flags() + self.set_flags_map.update({'mode': self.set_mode, + 'trigger_reverse': self.set_trigger_reverse, + 'relay_linked': self.set_relay_linked, + 'momentary_secs': self.set_momentary_secs}) + + #----------------------------------------------------------------------- + @property + def sensor_is_on(self): + """Returns the cached sensor state + """ + return self._sensor_is_on + + #----------------------------------------------------------------------- + @property + def relay_is_on(self): + """Returns the cached relay state + """ + return self._relay_is_on + #----------------------------------------------------------------------- @property def mode(self): @@ -303,270 +310,196 @@ def get_flags(self, on_done=None): seq.run() #----------------------------------------------------------------------- - def set_flags(self, on_done, **kwargs): - """Set internal device flags. - - This command is used to change internal device flags and states. See - the IOLinc user's guide for more information on what these do. Valid - inputs are: - - valid kwargs: - - mode = "latching", "momentary-a", "momentary-b", "momentary-c": - Change the relay mode. + def set_mode(self, on_done, **kwargs): + """Set momentary seconds. - - trigger_reverse = 1/0: Set the trigger reversing flag. - - - relay_linked = 1/0: Set the relay link flag. + Set the momentary mode Args: kwargs: Key=value pairs of the flags to change. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("IOLinc %s cmd: set operation flags", self.label) - - # Check the input flags to make sure only ones we can understand were - # passed in. - flags = set(["mode", "trigger_reverse", "relay_linked", - "momentary_secs"]) - unknown = set(kwargs.keys()).difference(flags) - if unknown: - LOG.error("Unknown IOLinc flags input: %s.\n Valid flags are: %s", - unknown, flags) - - seq = CommandSeq(self.protocol, "Device flags set", on_done, - name="SetFLags") + # Check for valid input + mode = util.input_choice(kwargs, 'mode', ['latching', 'momentary_a', + 'momentary_b', + 'momentary_c']) + if mode is None: + LOG.error("Invalid mode.") + on_done(False, 'Invalid mode.', None) + return # Loop through flags, sending appropriate command for each flag - for flag in kwargs: - if flag == 'mode': - try: - mode = IOLinc.Modes[kwargs[flag].upper()] - except KeyError: - mode = IOLinc.Modes.LATCHING - # Save this to the device metadata - self.mode = mode - if mode == IOLinc.Modes.LATCHING: - type_a = IOLinc.OperatingFlags.MOMENTARY_A_OFF - type_b = IOLinc.OperatingFlags.MOMENTARY_B_OFF - type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF - elif mode == IOLinc.Modes.MOMENTARY_A: - type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON - type_b = IOLinc.OperatingFlags.MOMENTARY_B_OFF - type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF - elif mode == IOLinc.Modes.MOMENTARY_B: - type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON - type_b = IOLinc.OperatingFlags.MOMENTARY_B_ON - type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF - elif mode == IOLinc.Modes.MOMENTARY_C: - type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON - type_b = IOLinc.OperatingFlags.MOMENTARY_B_ON - type_c = IOLinc.OperatingFlags.MOMENTARY_C_ON - for cmd2 in (type_a, type_b, type_c): - msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, - bytes([0x00] * 14)) - msg_handler = handler.StandardCmd(msg, - self.handle_set_flags) - seq.add_msg(msg, msg_handler) - - elif flag == 'trigger_reverse': - if util.input_bool(kwargs.copy(), "trigger_reverse"): - # Save this to the device metadata - self.trigger_reverse = True - cmd2 = IOLinc.OperatingFlags.INVERT_SENSOR_ON - else: - # Save this to the device metadata - self.trigger_reverse = False - cmd2 = IOLinc.OperatingFlags.INVERT_SENSOR_OFF - - msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, - bytes([0x00] * 14)) - msg_handler = handler.StandardCmd(msg, self.handle_set_flags) - seq.add_msg(msg, msg_handler) - - elif flag == 'relay_linked': - if util.input_bool(kwargs.copy(), "relay_linked"): - # Save this to the device metadata - self.relay_linked = True - cmd2 = IOLinc.OperatingFlags.RELAY_FOLLOWS_INPUT_ON - else: - # Save this to the device metadata - self.relay_linked = False - cmd2 = IOLinc.OperatingFlags.RELAY_FOLLOWS_INPUT_OFF - - msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, - bytes([0x00] * 14)) - msg_handler = handler.StandardCmd(msg, self.handle_set_flags) - seq.add_msg(msg, msg_handler) - - elif flag == 'momentary_secs': - # IOLinc allows setting the momentary time between 0.1 and - # 6300 seconds. At the low end with a resolution of .1 of a - # second. To store the higher numbers, a multiplier is used - # the multiplier as used by the insteon app has discrete steps - # 1, 10, 100, 200, and 250. No other steps are used. - dec_seconds = int(float(kwargs[flag]) * 10) - multiple = 0x01 - if dec_seconds > 51000: - multiple = 0xfa - elif dec_seconds > 25500: - multiple = 0xc8 - elif dec_seconds > 2550: - multiple = 0x64 - elif dec_seconds > 255: - multiple = 0x0a - - time_val = int(dec_seconds / multiple) - # Set the time value - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, - bytes([0x00, 0x06, time_val] + - [0x00] * 11)) - msg_handler = handler.StandardCmd(msg, self.handle_set_flags) - seq.add_msg(msg, msg_handler) - - # set the multiple - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, - bytes([0x00, 0x07, multiple, ] + - [0x00] * 11)) - msg_handler = handler.StandardCmd(msg, self.handle_set_flags) - seq.add_msg(msg, msg_handler) - - # Save this to the device metadata - self.momentary_secs = (dec_seconds * multiple) / 10 - - # Run all the commands. + try: + mode = IOLinc.Modes[mode.upper()] + except KeyError: + mode = IOLinc.Modes.LATCHING + # Save this to the device metadata + self.mode = mode + if mode == IOLinc.Modes.LATCHING: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_OFF + type_b = IOLinc.OperatingFlags.MOMENTARY_B_OFF + type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF + elif mode == IOLinc.Modes.MOMENTARY_A: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON + type_b = IOLinc.OperatingFlags.MOMENTARY_B_OFF + type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF + elif mode == IOLinc.Modes.MOMENTARY_B: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON + type_b = IOLinc.OperatingFlags.MOMENTARY_B_ON + type_c = IOLinc.OperatingFlags.MOMENTARY_C_OFF + elif mode == IOLinc.Modes.MOMENTARY_C: + type_a = IOLinc.OperatingFlags.MOMENTARY_A_ON + type_b = IOLinc.OperatingFlags.MOMENTARY_B_ON + type_c = IOLinc.OperatingFlags.MOMENTARY_C_ON + + seq = CommandSeq(self.protocol, "Set mode complete", + on_done, name="SetMode") + + for cmd2 in (type_a, type_b, type_c): + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, + bytes([0x00] * 14)) + callback = self.generic_ack_callback("Mode updated.") + msg_handler = handler.StandardCmd(msg, callback) + seq.add_msg(msg, msg_handler) + seq.run() #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): - """Refresh the current device state and database if needed. - - This sends a ping to the device. The reply has the current device - state (on/off, level, etc) and the current db delta value which is - checked against the current db value. If the current db is out of - date, it will trigger a download of the database. - - This will send out an updated signal for the current device status - whenever possible (like dimmer levels). + def set_trigger_reverse(self, on_done, **kwargs): + """Set momentary seconds. - This will update the state of both the sensor and the relay. + Whether the sensor trigger should be reversed. 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 - device model information even if it is already known. + kwargs: Key=value pairs of the flags to change. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("Device %s cmd: status refresh", self.label) - - # NOTE: IOLinc cmd1=0x00 will report the relay state. cmd2=0x01 - # reports the sensor state which is what we want. - seq = CommandSeq(self, "Device refreshed", on_done, name="DevRefresh") - - # This sends a refresh ping which will respond w/ the current - # database delta field. The handler checks that against the current - # value. If it's different, it will send a database download command - # to the device to update the database. - # This handles the relay state - msg = Msg.OutStandard.direct(self.addr, 0x19, 0x00) - msg_handler = handler.DeviceRefresh(self, self.handle_refresh_relay, - force, on_done, num_retry=3) - seq.add_msg(msg, msg_handler) - - # This Checks the sensor state, ignore force refresh here (we just did - # it above) - msg = Msg.OutStandard.direct(self.addr, 0x19, 0x01) - msg_handler = handler.DeviceRefresh(self, self.handle_refresh_sensor, - False, on_done, num_retry=3) - seq.add_msg(msg, msg_handler) + # Check for valid input + trig_rev = util.input_bool(kwargs, 'trigger_reverse') + if trig_rev is None: + LOG.error("Invalid trigger reverse.") + on_done(False, 'Invalid trigger reverse.', None) + return - # If model number is not known, or force true, run get_model - self.addRefreshData(seq, force) + self.trigger_reverse = trig_rev + cmd2 = IOLinc.OperatingFlags.INVERT_SENSOR_ON + if not trig_rev: + cmd2 = IOLinc.OperatingFlags.INVERT_SENSOR_OFF - # Run all the commands. - seq.run() + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, + bytes([0x00] * 14)) + callback = self.generic_ack_callback("Trigger reverse updated.") + msg_handler = handler.StandardCmd(msg, callback) + self.send(msg, msg_handler) #----------------------------------------------------------------------- - def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the relay on. + def set_relay_linked(self, on_done, **kwargs): + """Set momentary seconds. - This turns the relay on no matter what. It ignores the 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 state and emit - the state changed signals. + Whether the relay is linked to the sensor state. Args: - group (int): The group to send the command to. For this device, - this must be 1. Allowing a group here gives us a consistent - API to the on command across devices. - level (int): If non zero, turn the device on. Should be in the - range 0 to 255. Only dimmers use the intermediate values, all - other devices look at level=0 or level>0. - mode (on_off.Mode): The type of command to send (normal, fast, etc). + kwargs: Key=value pairs of the flags to change. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("IOLinc %s cmd: on", self.addr) - assert group == 0x01 - - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode + # Check for valid input + link_relay = util.input_bool(kwargs, 'relay_linked') + if link_relay is None: + LOG.error("Invalid relay linked.") + on_done(False, 'Invalid relay linked.', None) + return - # Send an on command. Use the standard command handler which will - # notify us when the command is ACK'ed. - msg = Msg.OutStandard.direct(self.addr, 0x11, 0xff) - msg_handler = handler.StandardCmd(msg, self.handle_ack, on_done) + self.relay_linked = link_relay + cmd2 = IOLinc.OperatingFlags.RELAY_FOLLOWS_INPUT_ON + if not link_relay: + cmd2 = IOLinc.OperatingFlags.RELAY_FOLLOWS_INPUT_OFF - # Send the message to the PLM modem. + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd2, + bytes([0x00] * 14)) + callback = self.generic_ack_callback("Flags updated.") + msg_handler = handler.StandardCmd(msg, callback) self.send(msg, msg_handler) #----------------------------------------------------------------------- - def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the relay off. + def set_momentary_secs(self, on_done, **kwargs): + """Set momentary seconds. - This turns the relay off no matter what. It ignores the momentary - A/B/C settings and just turns the relay off. It will not trigger any - responders that are linked to this device. If you want to control - the device where it respects the momentary settings and properly - updates responders, please define a scene for the device and use - that scene to control it. - - This will send the command to the device to update it's state. When - we get an ACK of the result, we'll change our internal state and emit - the state changed signals. + Sets the length of the momentary modes Args: - group (int): The group to send the command to. For this device, - this must be 1. Allowing a group here gives us a consistent - API to the on command across devices. - mode (on_off.Mode): The type of command to send (normal, fast, etc). + kwargs: Key=value pairs of the flags to change. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("IOLinc %s cmd: off", self.addr) - assert group == 0x01 + # Check for valid input + secs = util.input_float(kwargs, 'momentary_secs') + if secs is None: + LOG.error("Invalid seconds.") + on_done(False, 'Invalid seconds.', None) + return - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode + # 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(secs * 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 + + seq = CommandSeq(self.protocol, "Set Momentary Seconds complete", + on_done, name="SetMomenSecs") + + 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)) + callback = self.generic_ack_callback("Flags updated.") + msg_handler = handler.StandardCmd(msg, callback) + seq.add_msg(msg, msg_handler) - # Send an off command. Use the standard command handler which will - # notify us when the command is ACK'ed. - msg = Msg.OutStandard.direct(self.addr, 0x13, 0x00) - msg_handler = handler.StandardCmd(msg, self.handle_ack, on_done) + # set the multiple + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, + bytes([0x00, 0x07, multiple, ] + + [0x00] * 11)) + callback = self.generic_ack_callback("Flags updated.") + msg_handler = handler.StandardCmd(msg, callback) + seq.add_msg(msg, msg_handler) - # Send the message to the PLM modem. - self.send(msg, msg_handler) + # Save this to the device metadata + self.momentary_secs = (dec_seconds * multiple) / 10 + + seq.run() + + #----------------------------------------------------------------------- + def addRefreshData(self, seq, force=False): + """Add commands to refresh any internal data required. + + This Checks the sensor state, ignore force refresh here (we just did + it in refresh()) + + Args: + seq (CommandSeq): The command sequence to add the command to. + 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 + device model information even if it is already known. + """ + msg = Msg.OutStandard.direct(self.addr, 0x19, 0x01) + callback = functools.partial(self.handle_refresh, group=GROUP_SENSOR) + msg_handler = handler.DeviceRefresh(self, callback, False, num_retry=3) + seq.add_msg(msg, msg_handler) + super().addRefreshData(seq, force=force) #----------------------------------------------------------------------- def handle_on_off(self, msg): @@ -589,28 +522,15 @@ def handle_on_off(self, msg): Args: msg (InpStandard): Broadcast message from the device. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("IOLinc %s broadcast ACK grp: %s", self.addr, msg.group) - return + # If relay_linked is enabled then the relay was triggered + if self.relay_linked: + if msg.cmd1 == Msg.CmdType.ON: + self._set_state(group=GROUP_RELAY, is_on=True) + elif msg.cmd1 == Msg.CmdType.OFF: + self._set_state(group=GROUP_RELAY, is_on=False) - # On command. 0x11: on - elif msg.cmd1 == Msg.CmdType.ON: - 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 == Msg.CmdType.OFF: - 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) - - self.update_linked_devices(msg) + # Pass to Base to handle the sensor state + super().handle_on_off(msg) #----------------------------------------------------------------------- def handle_flags(self, msg, on_done): @@ -697,58 +617,30 @@ def handle_get_momentary(self, msg, on_done): on_done(True, "Operation complete", None) #----------------------------------------------------------------------- - def handle_set_flags(self, msg, on_done): - """Callback for handling flag change responses. - - This is called when we get a response to the set_flags command. - - Args: - msg (message.InpStandard): The refresh message reply. The msg.cmd2 - field represents the flag that was set. - """ - LOG.info("IOLinc Set Flag=%s", msg.cmd2) - on_done(True, "Operation complete", msg.cmd2) - - #----------------------------------------------------------------------- - def handle_refresh_relay(self, msg): - """Callback for handling refresh() responses for the relay - - This is called when we get a response to the first refresh() command. - The refresh command reply will contain the current device relay state - in cmd2 and this updates the device with that value. It is called by - handler.DeviceRefresh when we can an ACK for the refresh command. - - Args: - msg (message.InpStandard): The refresh message reply. The current - device 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 refresh(self, force=False, group=None, on_done=None): + """Refresh the current device state and database if needed. - #----------------------------------------------------------------------- - def handle_refresh_sensor(self, msg): - """Callback for handling refresh() responses for the sensor. + This sends a ping to the device. The reply has the current device + state (on/off, level, etc) and the current db delta value which is + checked against the current db value. If the current db is out of + date, it will trigger a download of the database. - 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. + This will send out an updated signal for the current device status + whenever possible (like dimmer levels). Args: - msg (message.InpStandard): The refresh message reply. The current - device sensor state is in the msg.cmd2 field. + 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 + device model information even if it is already known. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) """ - 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_sensor_is_on(msg.cmd2 > 0x00) + # Needed to pass the GROUP_RELAY data to base refresh() + group = group if group is not None else GROUP_RELAY + super().refresh(force=force, group=group, on_done=on_done) #----------------------------------------------------------------------- - def handle_ack(self, msg, on_done): + def handle_ack(self, msg, on_done, reason=""): """Callback for standard commanded messages. This callback is run when we get a reply back from one of our direct @@ -756,6 +648,8 @@ 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. + This overrides the function in SetAndState + These commands only affect the state of the relay. They respect the momentary_secs length. However: @@ -773,18 +667,11 @@ def handle_ack(self, msg, on_done): """ # This state is for the relay. LOG.debug("IOLinc %s ACK: %s", self.addr, msg) + reason = reason if reason else on_off.REASON_COMMAND + self._set_state(group=GROUP_RELAY, reason=reason, + is_on=msg.cmd1 == 0x11) 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) - #----------------------------------------------------------------------- def handle_group_cmd(self, addr, msg): """Respond to a group command for this device. @@ -834,66 +721,47 @@ def handle_group_cmd(self, addr, msg): is_on = True else: is_on = False - self._set_relay_is_on(is_on, on_off.REASON_SCENE) + self._set_state(group=GROUP_RELAY, is_on=is_on, + reason=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. +#----------------------------------------------------------------------- + def _cache_state(self, group, is_on, level, reason): + """Cache the State of the Device - Args: - is_on (bool): True if the sensor is on, False if it isn't. - """ - LOG.info("Setting device %s sensor on %s", self.label, is_on) - self._sensor_is_on = bool(is_on) - - self.signal_on_off.emit(self, self._sensor_is_on, self._relay_is_on) - - #----------------------------------------------------------------------- - def _set_relay_is_on(self, is_on, reason="", momentary=False): - """Update the device relay on/off state. - - This will change the internal state of the relay and emit the state - changed signals. It is called by whenever we're informed that the - device has changed state. + Used to help with the IOLinc unique functions. 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 + group (int): The group which this applies + is_on (bool): Whether the device is on. + level (int): The new device level in the range [0,255]. 0 is off. + reason (str): Reason string to pass around. """ - 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 + if group == 1: + self._sensor_is_on = bool(is_on) + elif group == 2: + self._relay_is_on = bool(is_on) + # handle latching and momentary functions + 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_state, + False, group=GROUP_RELAY, + reason=reason, + momentary=True) + elif not is_on and self._momentary_call: + if self.modem.timed_call.remove(self._momentary_call): + LOG.info("IOLinc %s relay off, removing delayed update", + self.label) + self._momentary_call = None #----------------------------------------------------------------------- def link_data_to_pretty(self, is_controller, data): @@ -934,15 +802,8 @@ def link_data_from_pretty(self, is_controller, data): 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'] + data_1, data_2, data_3 = super().link_data_from_pretty(is_controller, + data) if not is_controller: if 'on_off' in data: data_1 = 0xFF if data['on_off'] else 0x00 diff --git a/insteon_mqtt/device/KeypadLinc.py b/insteon_mqtt/device/KeypadLinc.py index e865992f..f7afd619 100644 --- a/insteon_mqtt/device/KeypadLinc.py +++ b/insteon_mqtt/device/KeypadLinc.py @@ -4,22 +4,19 @@ # #=========================================================================== import functools -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 -from .Base import Base -from . import functions -from . import Dimmer +from .base import ResponderBase +from .functions import Scene, Backlight, ManualCtrl LOG = log.get_logger() #=========================================================================== -class KeypadLinc(functions.Set, functions.Scene, Base): +class KeypadLinc(Scene, Backlight, ManualCtrl, ResponderBase): """Insteon KeypadLinc dimmer/switch device. This class can be used to model a 6 or 8 button KeypadLinc with dimming @@ -37,20 +34,11 @@ class KeypadLinc(functions.Set, functions.Scene, Base): 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_state( Device, int level, on_off.Mode mode, str reason): - Sent whenever the dimmer is turned on or off or changes level. The - level field will be in the range 0-255. For an on/off switch, this - will only emit 0 or 255. - - - signal_manual( Device, on_off.Manual mode, str reason ): Sent when the - device starts or stops manual mode (when a button is held down or - released). + the device (like sending MQTT messages). """ #----------------------------------------------------------------------- - def __init__(self, protocol, modem, address, name, dimmer=True): + def __init__(self, protocol, modem, address, name): """Constructor Args: @@ -66,23 +54,11 @@ def __init__(self, protocol, modem, address, name, dimmer=True): super().__init__(protocol, modem, address, name) # Switch or dimmer type. - self.is_dimmer = dimmer - self.type_name = "keypad_linc" if dimmer else "keypad_linc_sw" - - # Group on/off signal. - # API: func(Device, int group, int level, on_off.Mode mode, str reason) - self.signal_state = Signal() - - # Manual mode start up, down, off - # API: func(Device, int group, on_off.Manual mode, str reason) - self.signal_manual = Signal() + self.type_name = "keypad_linc_sw" # 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_flags' : self.set_flags, 'set_button_led' : self.set_button_led, 'set_load_attached' : self.set_load_attached, 'set_led_follow_mask' : self.set_led_follow_mask, @@ -91,12 +67,6 @@ def __init__(self, protocol, modem, address, name, dimmer=True): 'set_nontoggle_bits' : self.set_nontoggle_bits, }) - if self.is_dimmer: - self.cmd_map.update({ - 'increment_up' : self.increment_up, - 'increment_down' : self.increment_down, - }) - # TODO: these fields need to be stored in the database, updated when # changed, read on startup, and updated during a forced refresh or if # they don't exist in the database. @@ -130,6 +100,17 @@ def __init__(self, protocol, modem, address, name, dimmer=True): 0x07: self.handle_on_off, 0x08: self.handle_on_off}) + # List of responder group numbers + self.responder_groups = [1, 2, 3, 4, 5, 6, 7, 8, 9] + + # Define the flags handled by set_flags() + self.set_flags_map.update({'group': None, + 'load_attached': self.set_load_attached, + 'follow_mask': self.set_led_follow_mask, + 'off_mask': self.set_led_off_mask, + 'signal_bits': self.set_signal_bits, + 'nontoggle_bits': self.set_nontoggle_bits}) + #----------------------------------------------------------------------- @property def on_off_ramp_supported(self): @@ -145,7 +126,7 @@ def on_off_ramp_supported(self): self.db.desc.model == "2334-232") #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): + def refresh(self, force=False, group=None, on_done=None): """Refresh the current device state and database if needed. This sends a ping to the device. The reply has the current device @@ -163,43 +144,9 @@ def refresh(self, force=False, on_done=None): on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - # 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, "Refresh complete", on_done, name="DevRefresh") - - # TODO: change this to 0x2e get extended which reads on mask, off - # mask, on level, led brightness, non-toggle mask, led bit mask (led - # on/off), on/off bit mask, etc (see keypadlinc manual) - - # - # First send a refresh command which get's the state of the LED's by - # returning a bit flag. Pass skip_db here - we'll let the second - # refresh handler below take care of getting the database updated. - msg = Msg.OutStandard.direct(self.addr, 0x19, 0x01) - msg_handler = handler.DeviceRefresh(self, self.handle_refresh_led, - force=False, num_retry=3, - skip_db=True) - seq.add_msg(msg, msg_handler) - - # Send a refresh command to get the state of the load. This may or - # may not match the LED state depending on if detached load is set. - # This also responds 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, 0x19, 0x00) - msg_handler = handler.DeviceRefresh(self, self.handle_refresh, force, - None, num_retry=3) - seq.add_msg(msg, msg_handler) - - # Update any internal configuration data that we don't know (cats, - # firmware revisions, etc). If model number is not known, or force - # true, run get_model - self.addRefreshData(seq, force) - - # Run all the commands. - seq.run() + # Needed to pass the _load_group data to base refresh() + group = group if group is not None else self._load_group + super().refresh(force=force, group=group, on_done=on_done) #----------------------------------------------------------------------- def addRefreshData(self, seq, force=False): @@ -217,9 +164,20 @@ def addRefreshData(self, seq, force=False): even if the delta value matches as well as a re-query of the device model information even if it is already known. """ - Base.addRefreshData(self, seq, force) + super().addRefreshData(seq, force) - # TODO: update db. Only call if needed. + # TODO: change this to 0x2e get extended which reads on mask, off + # mask, on level, led brightness, non-toggle mask, led bit mask (led + # on/off), on/off bit mask, etc (see keypadlinc manual) + + # First send a refresh command which gets the state of the LED's by + # returning a bit flag. Pass skip_db here - we'll let the second + # refresh handler below take care of getting the database updated. + msg = Msg.OutStandard.direct(self.addr, 0x19, 0x01) + msg_handler = handler.DeviceRefresh(self, self.handle_refresh_led, + force=False, num_retry=3, + skip_db=True) + seq.add_msg(msg, msg_handler) # Get the state of which buttons toggle and the signal they emit. # Since the values we are interested in will be returned regardless @@ -231,233 +189,122 @@ def addRefreshData(self, seq, force=False): seq.add_msg(msg, msg_handler) #----------------------------------------------------------------------- - def on(self, group=0, level=None, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the device on. + def set(self, is_on=None, level=None, group=0x00, mode=on_off.Mode.NORMAL, + reason="", transition=None, on_done=None): + """Turn the device on or off. Level zero will be 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. If the input button is controlling the load (group 1 - for an attached load, group 0 for attached or detached), then the - load will turn on. Otherwise this command just changes the LED of - the button. + scene() method. 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 the state changed signals. Args: - group (int): The group to send the command to. If the group is 0, + is_on (bool): True to turn on, False for off + level (int): If non zero, turn the device on. Should be in the + range 0 to 255. If None, use default on-level. + group (int): The group to send the command to. If the group is 0, it will always be the load (whether it's attached or not). Otherwise it must be in the range [1,8] and controls the specific button. - level (int): If non zero, turn the device on. Should be in the - range 0 to 255. For non-dimmer groups, it will only look at - level=0 or level>0. If None, use default on-level. mode (on_off.Mode): The type of command to send (normal, fast, etc). reason (str): This is optional and is used to identify why the command was sent. It is passed through to the output signal when the state changes - nothing else is done with it. + transition (int): The transition ramp_rate if supported. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) - """ - LOG.info("KeypadLinc %s cmd: on grp %s %s mode %s ramp %s reason %s", - self.addr, group, level, mode, str(transition), reason) - - # If the group is 0, use the load group. - group = self._load_group if group == 0 else group - - LOG.debug("load group= %s group= %s", self._load_group, group) - - if level is None: - # Not specified - choose brightness as pressing the button would do - if group != self._load_group: - # Only load group can be a dimmer, use full-on for others - level = 0xff - if mode == on_off.Mode.FAST: - # Fast-ON command. Use full-brightness. - level = 0xff - else: - # Normal/instant ON command. Use default on-level. - # Check if we saved the default on-level in the device - # database when setting it. - level = self.get_on_level() - if self._level == level: - # Just like with button presses, if already at default on - # level, go to full brightness. - level = 0xff - - assert 1 <= group <= 9 - assert 0 <= level <= 0xff - assert isinstance(mode, on_off.Mode) - - # Non-load buttons are turned on/off via the LED command. - if group != self._load_group: - self.set_button_led(group, True, reason, on_done) - - # Load group uses a direct command to set the level. - else: - # For switches, on is always full level. - if not self.is_dimmer: - level = 0xff - - # Ignore RAMP mode / transition if command not supported - if mode == on_off.Mode.RAMP or transition is not None: - if not self.is_dimmer or not self.on_off_ramp_supported: - if self.db.desc is None: - LOG.error("Model info not in DB - ignoring ramp " - "rate. Use 'get_model %s' to retrieve.", - self.addr) - else: - LOG.error("Light ON at Ramp Rate not supported with " - "%s devices - ignoring specified ramp rate.", - self.db.desc.model) - transition = None - if mode == on_off.Mode.RAMP: - mode = on_off.Mode.NORMAL - - # Send the correct on code. - cmd1 = on_off.Mode.encode(True, mode) - cmd2 = on_off.Mode.encode_cmd2(True, mode, level, transition) - msg = Msg.OutStandard.direct(self.addr, cmd1, cmd2) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_set_load, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) + super().set(is_on=is_on, level=level, group=group, mode=mode, + reason=reason, transition=transition, on_done=on_done) #----------------------------------------------------------------------- - def off(self, group=0, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the device 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. If the input button is controlling the load (group 1 - for an attached load, group 0 for attached or detached), then the - load will turn off. Otherwise this command just changes the LED of - the button. + def on(self, group=0x00, level=None, mode=on_off.Mode.NORMAL, reason="", + transition=None, on_done=None): + """Turn the device on. - 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 - the state changed signals. + This is a wrapper around the SetAndState functions class, that adds + a few unique KPL functions. Args: - group (int): The group to send the command to. If the group is 0, + group (int): The group to send the command to. If the group is 0, it will always be the load (whether it's attached or not). Otherwise it must be in the range [1,8] and controls the specific button. + level (int): If non-zero, turn the device on. The API is an int + to keep a consistent API with other devices. mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Transition time in seconds if supported. reason (str): This is optional and is used to identify why the command was sent. It is passed through to the output signal when the state changes - nothing else is done with it. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("KeypadLinc %s cmd: off grp %s mode %s ramp %s reason %s", - self.addr, group, mode, str(transition), reason) - # If the group is 0, use the load group. group = self._load_group if group == 0 else group - assert 1 <= group <= 9 - assert isinstance(mode, on_off.Mode) - # Non-load buttons are turned on/off via the LED command. if group != self._load_group: - self.set_button_led(group, False, reason, on_done) - - # Load group uses a direct command to set the level. + self.set_button_led(group, True, reason, on_done) else: - # Ignore RAMP mode / transition if command not supported - if mode == on_off.Mode.RAMP or transition is not None: - if not self.is_dimmer or not self.on_off_ramp_supported: - if self.db.desc is None: - LOG.error("Model info not in DB - ignoring ramp " - "rate. Use 'get_model %s' to retrieve.", - self.addr) - else: - LOG.error("Light OFF at Ramp Rate not supported with " - "%s devices - ignoring specified ramp rate.", - self.db.desc.model) - transition = None - if mode == on_off.Mode.RAMP: - mode = on_off.Mode.NORMAL - - # Send an off or instant off command. - cmd1 = on_off.Mode.encode(False, mode) - cmd2 = on_off.Mode.encode_cmd2(True, mode, 0, transition) - msg = Msg.OutStandard.direct(self.addr, cmd1, cmd2) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_set_load, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) + # This is a regular on command pass to SetAndState class + super().on(group=group, level=level, mode=mode, reason=reason, + transition=transition, on_done=on_done) #----------------------------------------------------------------------- - def increment_up(self, reason="", on_done=None): - """Increment the current level up. - - Levels increment in units of 8 (32 divisions from off to on). + def off(self, group=0x00, mode=on_off.Mode.NORMAL, reason="", + transition=None, on_done=None): + """Turn the device off. - 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 - the state changed signals. + This is a wrapper around the SetAndState functions class, that adds + a few unique KPL functions. Args: + group (int): The group to send the command to. If the group is 0, + it will always be the load (whether it's attached or not). + Otherwise it must be in the range [1,8] and controls the + specific button. + mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Transition time in seconds if supported. reason (str): This is optional and is used to identify why the command was sent. It is passed through to the output signal when the state changes - nothing else is done with it. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - if not self.is_dimmer: - LOG.error("KeypadLinc %s switch doesn't support increment up " - "command", self.addr) - return - - LOG.info("KeypadLinc %s cmd: increment up", self.addr) - msg = Msg.OutStandard.direct(self.addr, 0x15, 0x00) + # If the group is 0, use the load group. + group = self._load_group if group == 0 else group - callback = functools.partial(self.handle_increment, delta=+8, - reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) + # Non-load buttons are turned on/off via the LED command. + if group != self._load_group: + self.set_button_led(group, False, reason, on_done) + else: + # This is a regular on command pass to SetAndState class + super().off(group=group, mode=mode, reason=reason, + transition=transition, on_done=on_done) #----------------------------------------------------------------------- - def increment_down(self, reason="", on_done=None): - """Increment the current level down. - - Levels increment in units of 8 (32 divisions from off to on). - - 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 - the state changed signals. + def mode_transition_supported(self, mode, transition): + """Adjust Mode and Transition based on Device Support Args: - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) + mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Ramp rate for the transition in seconds. + Returns + mode, transition: The adjusted values. """ - if not self.is_dimmer: - LOG.error("KeypadLinc %s switch doesn't support increment down " - "command", self.addr) - return - - LOG.info("KeypadLinc %s cmd: increment down", self.addr) - msg = Msg.OutStandard.direct(self.addr, 0x16, 0x00) - - callback = functools.partial(self.handle_increment, delta=-8, - reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) + # Ignore RAMP mode / transition if command not supported + if mode == on_off.Mode.RAMP or transition is not None: + LOG.error("Light ON at Ramp Rate not supported with " + "non-dimming devices - ignoring specified ramp rate.") + transition = None + if mode == on_off.Mode.RAMP: + mode = on_off.Mode.NORMAL + return (mode, transition) #----------------------------------------------------------------------- def link_data(self, is_controller, group, data=None): @@ -473,8 +320,8 @@ def link_data(self, is_controller, group, data=None): D3: the group number on the local device (0x01) For responders, the default fields are: - D1: on level for switches and dimmers (0xff) - D2: ramp rate (0x1f, or .1s) + D1: on level for switches (0xff) + D2: 0x00 D3: the group number on the local device (0x01) Args: @@ -496,10 +343,7 @@ def link_data(self, is_controller, group, data=None): # Responder data is always link dependent. Since nothing was given, # assume the user wants to turn the device on (0xff). else: - data_2 = 0x00 - if self.is_dimmer: - data_2 = 0x1f - defaults = [0xff, data_2, group] + defaults = [0xff, 0x00, group] # For each field, use the input if not -1, else the default. return util.resolve_data3(defaults, data) @@ -521,15 +365,6 @@ def link_data_to_pretty(self, is_controller, data): list[3]: list, containing a dict of the human readable values """ ret = [{'data_1': data[0]}, {'data_2': data[1]}, {'group': data[2]}] - if not is_controller: - if self.is_dimmer: - ramp = 0x1f # default - if data[1] in Dimmer.ramp_pretty: - ramp = Dimmer.ramp_pretty[data[1]] - on_level = int((data[0] / .255) + .5) / 10 - ret = [{'on_level': on_level}, - {'ramp_rate': ramp}, - {'group': data[2]}] return ret #----------------------------------------------------------------------- @@ -547,26 +382,9 @@ def link_data_from_pretty(self, is_controller, data): 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 'group' in data: - data_3 = data['group'] - if not is_controller and self.is_dimmer: - if 'ramp_rate' in data: - data_2 = 0x1f - for ramp_key, ramp_value in Dimmer.ramp_pretty.items(): - if data['ramp_rate'] >= ramp_value: - data_2 = ramp_key - break - if 'on_level' in data: - data_1 = int(data['on_level'] * 2.55 + .5) + data_1, data_2, data_3 = super().link_data_from_pretty(is_controller, + data) + data_3 = data.get('group', data_3) return [data_1, data_2, data_3] #----------------------------------------------------------------------- @@ -690,287 +508,7 @@ def set_button_led(self, group, is_on, reason="", on_done=None): self.send(msg, msg_handler) #----------------------------------------------------------------------- - def set_backlight(self, level, on_done=None): - """Set the device backlight level. - - This changes the level of the LED back light that is used by the - device status LED's (dimmer levels, KeypadLinc buttons, etc). - - The default factory level is 0x1f. - - Per page 157 of insteon dev guide range is between 0x11 and 0x7F, - however in practice backlight can be incremented from 0x00 to at least - 0x7f. - - Args: - level (int): The backlight level in the range [0,255] - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - - seq = CommandSeq(self, "KeypadLinc set backlight complete", on_done, - name="SetBacklight") - - # First set the backlight on or off depending on level value - is_on = level > 0 - LOG.info("KeypadLinc %s setting backlight to %s", self.label, is_on) - cmd = 0x09 if is_on else 0x08 - msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, bytes([0x00] * 14)) - callback = functools.partial(self.handle_ack, task="Backlight on") - msg_handler = handler.StandardCmd(msg, callback, on_done) - seq.add_msg(msg, msg_handler) - - if is_on: - # Second set the level only if on - LOG.info("KeypadLinc %s setting backlight to %s", self.label, - level) - - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x07, # D2 set global led brightness - level, # D3 brightness level - ] + [0x00] * 11) - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_ack, - task="Backlight level") - msg_handler = handler.StandardCmd(msg, callback, on_done) - seq.add_msg(msg, msg_handler) - - seq.run() - - #----------------------------------------------------------------------- - def get_flags(self, on_done=None): - """Hijack base get_flags to inject extended flags request. - - 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) - """ - seq = CommandSeq(self, "Dimmer get_flags complete", on_done, - name="GetFlags") - seq.add(super().get_flags) - seq.add(self._get_ext_flags) - seq.run() - - #----------------------------------------------------------------------- - def _get_ext_flags(self, on_done=None): - """Get the Insteon operational extended flags field from the device. - - For the dimmer device, the flags include on-level and ramp-rate. - - Args: - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Dimmer %s cmd: get extended operation flags", self.label) - - # D1 = group (0x01), D2 = 0x00 == Data Request, others ignored, - # per Insteon Dev Guide - data = bytes([0x01] + [0x00] * 13) - - msg = Msg.OutExtended.direct(self.addr, Msg.CmdType.EXTENDED_SET_GET, - 0x00, data) - msg_handler = handler.ExtendedCmdResponse(msg, self.handle_ext_flags, - on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def handle_ext_flags(self, msg, on_done): - """Handle replies to the _get_ext_flags command. - - Extended message payload is: - D8 = on-level - D7 = ramp-rate - - Args: - msg (message.InpExtended): The message reply. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - on_level = msg.data[7] - self.db.set_meta('on_level', on_level) - ramp_rate = msg.data[6] - for ramp_key, ramp_value in Dimmer.ramp_pretty.items(): - if ramp_rate <= ramp_key: - ramp_rate = ramp_value - break - LOG.ui("Dimmer %s on_level: %s (%.2f%%) ramp rate: %ss", self.label, - on_level, on_level / 2.55, ramp_rate) - on_done(True, "Operation complete", msg.data[5]) - - #----------------------------------------------------------------------- - def set_on_level(self, level, on_done=None): - """Set the device default on level. - - This changes the dimmer level the device will go to when the on - button is pressed. This can be very useful because a double-tap - (fast-on) will the turn the device to full brightness if needed. - - Args: - level (int): The default on level in the range [0,255] - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - if not self.is_dimmer: - LOG.error("KeypadLinc %s switch doesn't support setting on level", - self.addr) - return - - LOG.info("KeypadLinc %s setting on level to %s", self.label, level) - - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x06, # D2 set on level when button is pressed - level, # D3 brightness level - ] + [0x00] * 11) - - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_on_level, level=level) - msg_handler = handler.StandardCmd(msg, callback, on_done) - - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def set_ramp_rate(self, rate, on_done=None): - """Set the device default ramp rate. - - This changes the dimmer default ramp rate of how quickly the it - will turn on or off. This rate can be between 0.1 seconds and up - to 9 minutes. - - Args: - rate (float): Ramp rate in in the range [0.1, 540] seconds - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - if not self.is_dimmer: - LOG.error("KeypadLinc %s switch doesn't support setting ramp_rate", - self.addr) - return - - LOG.info("Dimmer %s setting ramp rate to %s", self.label, rate) - - data_3 = 0x1c # the default ramp rate is .5 - for ramp_key, ramp_value in Dimmer.ramp_pretty.items(): - if rate >= ramp_value: - data_3 = ramp_key - break - - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x05, # D2 set ramp rate when button is pressed - data_3, # D3 rate - ] + [0x00] * 11) - - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_ack, task="Button ramp rate") - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def set_flags(self, on_done, **kwargs): - """Set internal device flags. - - This command is used to change internal device flags and states. - Valid inputs are: - - - backlight=level: Change the backlight LED level (0-255). See - set_backlight() for details. - - - on_level=level: Change the default device on level (0-255) See - set_on_level for details. - - Args: - kwargs: Key=value pairs of the flags to change. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("KeypadLinc %s cmd: set flags", self.label) - - # Check the input flags to make sure only ones we can understand were - # passed in. - FLAG_BACKLIGHT = "backlight" - FLAG_GROUP = "group" - FLAG_ON_LEVEL = "on_level" - FLAG_RAMP_RATE = "ramp_rate" - FLAG_LOAD_ATTACH = "load_attached" - FLAG_FOLLOW_MASK = "follow_mask" - FLAG_OFF_MASK = "off_mask" - FLAG_SIGNAL_BITS = "signal_bits" - FLAG_NONTOGGLE_BITS = "nontoggle_bits" - flags = set([FLAG_BACKLIGHT, FLAG_LOAD_ATTACH, FLAG_FOLLOW_MASK, - FLAG_SIGNAL_BITS, FLAG_NONTOGGLE_BITS, FLAG_OFF_MASK, - FLAG_GROUP, FLAG_ON_LEVEL, FLAG_RAMP_RATE]) - unknown = set(kwargs.keys()).difference(flags) - if unknown: - LOG.error("Unknown KeypadLinc flags input: %s.\n Valid " - "flags are: %s", unknown, flags) - - # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self, "KeypadLinc set_flags complete", - on_done, name="SetFlags") - - # Get the group if it was set. - group = util.input_integer(kwargs, FLAG_GROUP) - - if FLAG_BACKLIGHT in kwargs: - backlight = util.input_byte(kwargs, FLAG_BACKLIGHT) - seq.add(self.set_backlight, backlight) - - if FLAG_LOAD_ATTACH in kwargs: - load_attached = util.input_bool(kwargs, FLAG_LOAD_ATTACH) - seq.add(self.set_load_attached, load_attached) - - if FLAG_ON_LEVEL in kwargs: - on_level = util.input_byte(kwargs, FLAG_ON_LEVEL) - seq.add(self.set_on_level, on_level) - - if FLAG_RAMP_RATE in kwargs: - rate = util.input_float(kwargs, FLAG_RAMP_RATE) - seq.add(self.set_ramp_rate, rate) - - if FLAG_FOLLOW_MASK in kwargs: - if group is None: - raise Exception("follow_mask requires group= to be input") - - mask = util.input_byte(kwargs, FLAG_FOLLOW_MASK) - seq.add(self.set_led_follow_mask, group, mask) - - if FLAG_OFF_MASK in kwargs: - if group is None: - raise Exception("off_mask requires group= to be input") - - mask = util.input_byte(kwargs, FLAG_OFF_MASK) - seq.add(self.set_led_off_mask, group, mask) - - if FLAG_SIGNAL_BITS in kwargs: - bits = util.input_byte(kwargs, FLAG_SIGNAL_BITS) - seq.add(self.set_signal_bits, bits) - - if FLAG_NONTOGGLE_BITS in kwargs: - bits = util.input_byte(kwargs, FLAG_NONTOGGLE_BITS) - seq.add(self.set_nontoggle_bits, bits) - - seq.run() - - #----------------------------------------------------------------------- - def set_led_follow_mask(self, group, mask, on_done=None): + def set_led_follow_mask(self, on_done=None, **kwargs): """Set the LED follow mask. The LED follow mask is a bitmask defined for each group (button), @@ -992,6 +530,18 @@ def set_led_follow_mask(self, group, mask, on_done=None): """ on_done = util.make_callback(on_done) + # Check for valid input + group = util.input_byte(kwargs, 'group') + if group is None: + LOG.error("follow_mask requires group= to be input") + on_done(False, 'Invalid group specified.', None) + return + mask = util.input_byte(kwargs, 'follow_mask') + if mask is None: + LOG.error("Invalid follow mask.") + on_done(False, 'Invalid follow mask.', None) + return + task = "button {} follow mask: {:08b}".format(group, mask) LOG.info("KeypadLinc %s setting %s", self.addr, task) @@ -1019,7 +569,7 @@ def set_led_follow_mask(self, group, mask, on_done=None): # Use the standard command handler which will notify us when the # command is ACK'ed. - callback = functools.partial(self.handle_ack, task=task) + callback = self.generic_ack_callback(task) msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) @@ -1028,7 +578,7 @@ def set_led_follow_mask(self, group, mask, on_done=None): # TODO: get button follow masks and save in db as part of refresh. #----------------------------------------------------------------------- - def set_led_off_mask(self, group, mask, on_done=None): + def set_led_off_mask(self, on_done=None, **kwargs): """Set the LED off mask. The LED off mask is a bitmask defined for each group (button). The @@ -1051,6 +601,18 @@ def set_led_off_mask(self, group, mask, on_done=None): """ on_done = util.make_callback(on_done) + # Check for valid input + group = util.input_byte(kwargs, 'group') + if group is None: + LOG.error("off_mask requires group= to be input") + on_done(False, 'Invalid group specified.', None) + return + mask = util.input_byte(kwargs, 'off_mask') + if mask is None: + LOG.error("Invalid off mask.") + on_done(False, 'Invalid off mask.', None) + return + task = "button {} off mask: {:08b}".format(group, mask) LOG.info("KeypadLinc %s setting %s", self.addr, task) @@ -1078,7 +640,7 @@ def set_led_off_mask(self, group, mask, on_done=None): # Use the standard command handler which will notify us when the # command is ACK'ed. - callback = functools.partial(self.handle_ack, task=task) + callback = self.generic_ack_callback(task) msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) @@ -1087,7 +649,7 @@ def set_led_off_mask(self, group, mask, on_done=None): # TODO: get button off masks and save in db as part of refresh. #----------------------------------------------------------------------- - def set_signal_bits(self, signal_bits, on_done=None): + def set_signal_bits(self, on_done=None, **kwargs): """Set which signal is emitted for non-toggle buttons. This is a bit flag set (one bit per button) that is used when a @@ -1101,6 +663,13 @@ def set_signal_bits(self, signal_bits, on_done=None): on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ + # Check for valid input + signal_bits = util.input_byte(kwargs, 'signal_bits') + if signal_bits is None: + LOG.error("Invalid signal bits.") + on_done(False, 'Invalid signal bits.', None) + return + task = "signal bits: {:08b}".format(signal_bits) LOG.info("KeypadLinc %s setting %s", self.label, task) @@ -1115,12 +684,12 @@ def set_signal_bits(self, signal_bits, on_done=None): # Use the standard command handler which will notify us when the # command is ACK'ed. - callback = functools.partial(self.handle_ack, task=task) + callback = self.generic_ack_callback(task) msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) #----------------------------------------------------------------------- - def set_nontoggle_bits(self, nontoggle_bits, on_done=None): + def set_nontoggle_bits(self, on_done=None, **kwargs): """Set a button to be a toggle or non-toggle button. If a bit is zero, it's a toggle button which is the normal behavior. @@ -1137,6 +706,13 @@ def set_nontoggle_bits(self, nontoggle_bits, on_done=None): on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ + # Check for valid input + nontoggle_bits = util.input_byte(kwargs, 'nontoggle_bits') + if nontoggle_bits is None: + LOG.error("Invalid nontoggle bits.") + on_done(False, 'Invalid nontoggle bits.', None) + return + task = "nontoggle bits: {:08b}".format(nontoggle_bits) LOG.info("KeypadLinc %s setting %s", self.label, task) @@ -1151,75 +727,10 @@ def set_nontoggle_bits(self, nontoggle_bits, on_done=None): # Use the standard command handler which will notify us when the # command is ACK'ed. - callback = functools.partial(self.handle_ack, task=task) + callback = self.generic_ack_callback(task) msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) - #----------------------------------------------------------------------- - def handle_ack(self, msg, on_done, task=""): - """Callback for handling standard ack/nak. - - Other that reporting the result, no other action is taken. It's used - for commands that don't need any more processing. - - Args: - msg (InpStandard): The response message from the command. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - task (str): The message to report. - """ - on_done(True, "%s updated" % task, None) - - #----------------------------------------------------------------------- - def handle_on_level(self, msg, on_done, level): - """Callback for handling set_on_level() responses. - - This is called when we get a response to the set_on_level() command. - Update stored on-level in device DB and call the on_done callback with - the status. - - Args: - msg (InpStandard): The response message from the command. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - self.db.set_meta('on_level', level) - on_done(True, "Button on level updated", None) - - #----------------------------------------------------------------------- - def get_on_level(self): - """Look up previously-set on-level in device database, if present - - This is called when we need to look up what is the default on-level - (such as when getting an ON broadcast message from the device). - - If on_level is not found in the DB, assumes on-level is full-on. - """ - on_level = self.db.get_meta('on_level') - if on_level is None: - on_level = 0xff - return on_level - - #----------------------------------------------------------------------- - def handle_refresh(self, msg): - """Handle replies to the refresh command. - - The refresh command reply will contain the current device load group - state in cmd2 and this updates the device with that value. - - Args: - msg: (message.InpStandard) The refresh message reply. The current - device state is in the msg.cmd2 field. - """ - # NOTE: This is called by the handler.DeviceRefresh class when the - # refresh message send by Base.refresh is ACK'ed. - LOG.ui("KeypadLinc %s refresh at level %s", self.addr, msg.cmd2) - - # Current load group level is stored in cmd2 so update our level to - # match. - self._set_level(self._load_group, msg.cmd2, - reason=on_off.REASON_REFRESH) - #----------------------------------------------------------------------- def handle_button_led(self, msg, on_done, group, is_on, led_bits, reason=""): @@ -1255,7 +766,8 @@ def handle_button_led(self, msg, on_done, group, is_on, led_bits, "{:08b}".format(self._led_bits)) # Change the level and emit the active signal. - self._set_level(group, 0xff if is_on else 0x00, reason=reason) + self._set_state(group=group, level=0xff if is_on else 0x00, + reason=reason) msg = "KeypadLinc %s LED group %s updated to %s" % \ (self.addr, group, is_on) @@ -1313,7 +825,8 @@ def handle_refresh_led(self, msg): LOG.debug("Btn %d old: %d new %d", i + 1, is_on, was_on) if is_on != was_on: - self._set_level(i + 1, 0xff if is_on else 0x00, reason=reason) + self._set_state(group=i + 1, level=0xff if is_on else 0x00, + reason=reason) self._led_bits = led_bits @@ -1363,7 +876,7 @@ def handle_refresh_state(self, msg, on_done): #LED LOG.debug("Btn %d old: %d new %d", i + 1, is_on, was_on) #LED if is_on != was_on: - #LED self._set_level(i + 1, 0xff if is_on else 0x00, + #LED self._set_state(i + 1, 0xff if is_on else 0x00, #LED reason=reason) #LED self._led_bits = led_bits @@ -1371,234 +884,67 @@ def handle_refresh_state(self, msg, on_done): on_done(True, "Refresh complete", None) #----------------------------------------------------------------------- - def handle_on_off(self, msg): - """Handle on_off broadcast messages from this device. + def react_to_manual(self, manual, group, reason): + """React to Manual Mode Received from the Device - This is called from base.handle_broadcast using the group_map map. + Non-dimmable devices react immediatly when issueing a manual command + while dimmable devices slowly ramp on. This function is here to + provide DimmerBase a place to alter the default functionality. This + function should call _set_state() at the appropriate times to update + the state of the device. Args: - msg (InpStandard): Broadcast message from the device. + manual (on_off.Manual): The manual command type + group (int): The group sending the command + reason (str): The reason string to pass on """ - # 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. - - # If we have a saved reason from a simulated scene command, use that. - # Otherwise the device button was pressed. - reason = self.broadcast_reason if self.broadcast_reason else \ - on_off.REASON_DEVICE - self.broadcast_reason = "" - - # ACK of the broadcast. Ignore this unless we sent a simulated off - # scene in which case run the broadcast done handler. This is a - # weird special case - see scene() for details. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("KeypadLinc %s broadcast ACK grp: %s", self.addr, - msg.group) - return - - # On/off commands. - elif on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - LOG.info("KeypadLinc %s broadcast grp: %s on: %s mode: %s", - self.addr, msg.group, is_on, mode) - - # For an on command, we can update directly. - if is_on: - # Level isn't provided in the broadcast msg. - # What to use depends on which command was received. - if msg.group != self._load_group: - # Only load group can be a dimmer, use full-on for others - level = 0xff - elif mode == on_off.Mode.FAST: - # Fast-ON command. Use full-brightness. - level = 0xff - else: - # Normal/instant ON command. Use default on-level. - # Check if we saved the default on-level in the device - # database when setting it. - level = self.get_on_level() - if self._level == level: - # Pressing on again when already at the default on - # level causes the device to go to full-brightness. - level = 0xff - self._set_level(msg.group, level, mode, reason) - - else: - self._set_level(msg.group, 0x00, mode, reason) - - # 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, button=msg.group, manual=manual, - reason=reason) - + if group == self._load_group: + # Switches change state when the switch is held. + if manual == on_off.Manual.UP: + self._set_state(group=self._load_group, level=0xff, + mode=on_off.Mode.MANUAL, + reason=reason) + elif manual == on_off.Manual.DOWN: + self._set_state(group=self._load_group, level=0x00, + mode=on_off.Mode.MANUAL, + reason=reason) + else: # Non-load group buttons don't change state in manual mode. (found - # through experiments) - if msg.group == self._load_group: - # Switches change state when the switch is held. - if not self.is_dimmer: - if manual == on_off.Manual.UP: - self._set_level(0xff, on_off.Mode.MANUAL, reason) - elif manual == on_off.Manual.DOWN: - self._set_level(0x00, on_off.Mode.MANUAL, reason) - - # 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() - - self.update_linked_devices(msg) - - #----------------------------------------------------------------------- - def handle_set_load(self, msg, on_done, reason=""): - """Callback for standard commanded messages to the load button. - - This callback is run when we get a reply back from one of our - commands to the device for changing the load (usually group 1). 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. - - Args: - msg: (message.InpStandard) The reply message from the device. - The on/off level will be in the cmd2 field. - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - """ - # If this is the ACK we're expecting, update the internal state and - # emit our signals. - LOG.debug("KeypadLinc %s ACK: %s", self.addr, msg) - - _is_on, mode = on_off.Mode.decode(msg.cmd1) - level = on_off.Mode.decode_level(msg.cmd1, msg.cmd2) - self._set_level(self._load_group, level, mode, reason) - on_done(True, "KeypadLinc state updated to %s" % self._level, - level) + # through experiments). It looks like they turn on from off but + # not off from on?? Use refresh to be sure. + if manual == on_off.Manual.STOP: + self.refresh() #----------------------------------------------------------------------- - def handle_increment(self, msg, on_done, delta, reason=""): - """Callback for increment up/down commanded messages. + def group_cmd_local_group(self, entry): + """Get the Local Group Affected by this Group Command - This callback is run when we get a reply back from triggering an - increment up or down on the device. If the command was ACK'ed, we - know it worked. + For most devices this is group 1, but for multigroup devices such + as the KPL, they may need to decode the local group from the + entry data. 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) - delta (int): The amount +/- of level to change by. - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. + entry (DeviceEntry): The local db entry for this group command. + Returns: + group (int): The local group affected """ - # If this it the ACK we're expecting, update the internal state and - # emit our signals. - LOG.debug("KeypadLinc %s ACK: %s", self.addr, msg) - - # Add the delta and bound at [0, 255] - level = min(self._level + delta, 255) - level = max(level, 0) - self._set_level(self._load_group, level, reason=reason) - - s = "KeypadLinc %s state updated to %s" % (self.addr, self._level) - on_done(True, s, msg.cmd2) + return entry.data[2] #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, msg): - """Respond to a group command for this device. + def _cache_state(self, group, is_on, level, reason): + """Cache the State of the Device - This is called when this device is a responder to a scene. The - device that received the broadcast message (handle_broadcast) will - call this method for every device that is linked to it. The device - should look up the responder entry for the group in it's all link - database and update it's state accordingly. + Used to help with the KPL unique functions. Args: - addr (Address): The device that sent the message. This is the - controller in the scene. - msg (InpStandard): Broadcast message from the device. Use - msg.group to find the group and msg.cmd1 for the command. + group (int): The group which this applies + is_on (bool): Whether the device is on. + level (int): The new device level in the range [0,255]. 0 is off. + reason (str): Reason string to pass around. """ - # Make sure we're really a responder to this message. This shouldn't - # ever occur. - entry = self.db.find(addr, msg.group, is_controller=False) - if not entry: - LOG.error("KeypadLinc %s has no group %s entry from %s", self.addr, - msg.group, addr) - return - - reason = on_off.REASON_SCENE - - # The local button being modified is stored in the db entry. - localGroup = entry.data[2] - - # Handle on/off codes - if on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - - # For switches, on/off determines the level. For dimmers, it's - # set by the responder entry in the database. - level = 0xff if is_on else 0x00 - if self.is_dimmer and is_on and localGroup == self._load_group: - level = entry.data[0] - - self._set_level(localGroup, level, mode, reason) - - # Increment up 1 unit which is 8 levels. - elif msg.cmd1 == 0x15: - assert localGroup == self._load_group - self._set_level(localGroup, min(0xff, self._level + 8), - reason=reason) - - # Increment down 1 unit which is 8 levels. - elif msg.cmd1 == 0x16: - assert msg.group == self._load_group - self._set_level(localGroup, max(0x00, self._level - 8), - reason=reason) - - # 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, button=localGroup, manual=manual, - reason=reason) - - # If the button is released, refresh to get the final level in - # dimming mode since we don't know where the level stopped. - if manual == on_off.Manual.STOP: - self.refresh() - - else: - LOG.warning("KeypadLinc %s unknown cmd %#04x", self.addr, - msg.cmd1) - - #----------------------------------------------------------------------- - def _set_level(self, group, level, mode=on_off.Mode.NORMAL, reason=""): - """Update the device level state for a group. - - 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. - - Args: - group (int): The group number to update [1,8]. - level (int): The new device level in the range [0,255]. 0 is off. - mode (on_off.Mode): The type of on/off that was triggered (normal, - fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - """ - LOG.info("Setting device %s grp=%s on=%s %s%s", self.label, group, - level, mode, reason) - + if is_on is not None: + self._is_on = is_on + group = 0x01 if group is None else group if group == self._load_group: self._level = level @@ -1607,7 +953,4 @@ def _set_level(self, group, level, mode=on_off.Mode.NORMAL, reason=""): self._led_bits = util.bit_set(self._led_bits, group - 1, 1 if level else 0) - self.signal_state.emit(self, button=group, level=level, mode=mode, - reason=reason) - #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/KeypadLincDimmer.py b/insteon_mqtt/device/KeypadLincDimmer.py new file mode 100644 index 00000000..0248b98b --- /dev/null +++ b/insteon_mqtt/device/KeypadLincDimmer.py @@ -0,0 +1,326 @@ +#=========================================================================== +# +# KeypadLinc Dimmer module +# +#=========================================================================== +from ..CommandSeq import CommandSeq +from .. import handler +from .. import log +from .. import message as Msg +from .. import on_off +from .. import util +from .base import DimmerBase +from .KeypadLinc import KeypadLinc + +LOG = log.get_logger() + + +#=========================================================================== +class KeypadLincDimmer(KeypadLinc, DimmerBase): + """Insteon KeypadLinc Dimmer Device. + + This class extends the KeypadLinc device to add dimmer functionality. + + This class can be used to model a 6 or 8 button KeypadLinc with dimming + functionality. The buttons are numbered 1...8. In the 6 button, model, + the top and bottom buttons are combined (so buttons 2 and 7 are unused). + If the load is detached (meaning button 1 is not controlling the load), + then a virtual button 9 is used to control the load. + + If a button is not controlling the load then it's on/off state only + relates to whether or not the button LED is on or off. For example, + setting button group 3 to on just turns the LED on. In many cases, a + scene command to button 3 is what you want (to simulate pressing the + button). + + 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). + """ + + #----------------------------------------------------------------------- + def __init__(self, protocol, modem, address, name): + """Constructor + + Args: + protocol: (Protocol) The Protocol object used to communicate + with the Insteon network. This is needed to allow + the device to send messages to the PLM modem. + modem: (Modem) The Insteon modem used to find other devices. + address: (Address) The address of the device. + name: (str) Nice alias name to use for the device. + dimmer: (bool) True if the device supports dimming - False if + it's a regular switch. + """ + super().__init__(protocol, modem, address, name) + + # Switch or dimmer type. + # Here for compatibility purposes can likely be removed eventually. + self.type_name = "keypad_linc" + + #----------------------------------------------------------------------- + def mode_transition_supported(self, mode, transition): + """Adjust Mode and Transition based on Device Support + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Ramp rate for the transition in seconds. + Returns + mode, transition: The adjusted values. + """ + # Ignore RAMP mode / transition if command not supported + if mode == on_off.Mode.RAMP or transition is not None: + if not self.on_off_ramp_supported: + if self.db.desc is None: + LOG.error("Model info not in DB - ignoring ramp " + "rate. Use 'get_model %s' to retrieve.", + self.addr) + else: + LOG.error("Light ON at Ramp Rate not supported with " + "%s devices - ignoring specified ramp rate.", + self.db.desc.model) + transition = None + if mode == on_off.Mode.RAMP: + mode = on_off.Mode.NORMAL + return (mode, transition) + + #----------------------------------------------------------------------- + def cmd_on_values(self, mode, level, transition, group): + """Calculate Cmd Values for On + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + level (int): On level between 0-255. + transition (int): Ramp rate for the transition in seconds. + group (int): The group number that this state applies to. Defaults + to None. + Returns + cmd1, cmd2 (int): Value of cmds for this device. + """ + if level is None: + # If level is not specified it uses the level that the device + # would go to if the button was physically pressed. + level = self.derive_on_level(mode) + + mode, transition = self.mode_transition_supported(mode, transition) + + cmd1 = on_off.Mode.encode(True, mode) + cmd2 = on_off.Mode.encode_cmd2(True, mode, level, transition) + return (cmd1, cmd2) + + #----------------------------------------------------------------------- + def cmd_off_values(self, mode, transition, group): + """Calculate Cmd Values for Off + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Ramp rate for the transition in seconds. + group (int): The group number that this state applies to. Defaults + to None. + Returns + cmd1, cmd2 (int): Value of cmds for this device. + """ + # Ignore RAMP mode / transition if command not supported + mode, transition = self.mode_transition_supported(mode, transition) + cmd1 = on_off.Mode.encode(False, mode) + cmd2 = on_off.Mode.encode_cmd2(True, mode, 0, transition) + return (cmd1, cmd2) + + #----------------------------------------------------------------------- + def link_data(self, is_controller, group, data=None): + """Create default device 3 byte link data. + + This is the 3 byte field (D1, D2, D3) stored in the device database + entry. This overrides the defaults specified in base.py for + specific values used by dimming devices. + + For controllers, the default fields are: + D1: number of retries (0x03) + D2: unknown (0x00) + D3: the group number on the local device (0x01) + + For responders, the default fields are: + D1: on level for switches and dimmers (0xff) + D2: ramp rate (0x1f, or .1s) + D3: the group number on the local device (0x01) + + Args: + is_controller (bool): True if the device is the controller, false + if it's the responder. + group (int): The group number of the controller button or the + group number of the responding button. + data (bytes[3]): Optional 3 byte data entry. If this is None, + defaults are returned. Otherwise it must be a 3 element list. + Any element that is not None is replaced with the default. + + Returns: + bytes[3]: Returns a list of 3 bytes to use as D1,D2,D3. + """ + # Most of this is from looking through Misterhouse bug reports. + if is_controller: + defaults = [0x03, 0x00, group] + + # Responder data is always link dependent. Since nothing was given, + # assume the user wants to turn the device on (0xff). + else: + defaults = [0xff, 0x1f, group] + + # For each field, use the input if not -1, else the default. + return util.resolve_data3(defaults, data) + + #----------------------------------------------------------------------- + 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]}, {'group': data[2]}] + if not is_controller: + ramp = 0x1f # default + if data[1] in self.ramp_pretty: + ramp = self.ramp_pretty[data[1]] + on_level = int((data[0] / .255) + .5) / 10 + ret = [{'on_level': on_level}, + {'ramp_rate': ramp}, + {'group': 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, data_2, data_3 = super().link_data_from_pretty(is_controller, + data) + if not is_controller: + if 'ramp_rate' in data: + data_2 = 0x1f + for ramp_key, ramp_value in self.ramp_pretty.items(): + if data['ramp_rate'] >= ramp_value: + data_2 = ramp_key + break + if 'on_level' in data: + data_1 = int(data['on_level'] * 2.55 + .5) + return [data_1, data_2, data_3] + + #----------------------------------------------------------------------- + def group_cmd_on_level(self, entry, is_on): + """Get the On Level for this Group Command + + For switches, this always returns None as this forces template_data + in the MQTT classes to render without level data to comply with prior + versions. But dimmers allow for the local on_level to be user defined + and stored in the db entry. + + Args: + entry (DeviceEntry): The local db entry for this group command. + is_on (bool): Whether the command was ON or OFF + Returns: + level (int): The on_level or None + """ + level = 0xFF if is_on else 0x00 + if is_on and entry.data[2] == self._load_group: + level = entry.data[0] + return level + + #----------------------------------------------------------------------- + def get_flags(self, on_done=None): + """Hijack base get_flags to inject extended flags request. + + 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) + """ + seq = CommandSeq(self, "Dimmer get_flags complete", on_done, + name="GetFlags") + seq.add(super().get_flags) + seq.add(self._get_ext_flags) + seq.run() + + #----------------------------------------------------------------------- + def _get_ext_flags(self, on_done=None): + """Get the Insteon operational extended flags field from the device. + + For the dimmer device, the flags include on-level and ramp-rate. + + Args: + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("Dimmer %s cmd: get extended operation flags", self.label) + + # D1 = group (0x01), D2 = 0x00 == Data Request, others ignored, + # per Insteon Dev Guide + data = bytes([0x01] + [0x00] * 13) + + msg = Msg.OutExtended.direct(self.addr, Msg.CmdType.EXTENDED_SET_GET, + 0x00, data) + msg_handler = handler.ExtendedCmdResponse(msg, self.handle_ext_flags, + on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def handle_ext_flags(self, msg, on_done): + """Handle replies to the _get_ext_flags command. + + Extended message payload is: + D8 = on-level + D7 = ramp-rate + + Args: + msg (message.InpExtended): The message reply. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + on_level = msg.data[7] + self.db.set_meta('on_level', on_level) + ramp_rate = msg.data[6] + for ramp_key, ramp_value in self.ramp_pretty.items(): + if ramp_rate <= ramp_key: + ramp_rate = ramp_value + break + LOG.ui("Dimmer %s on_level: %s (%.2f%%) ramp rate: %ss", self.label, + on_level, on_level / 2.55, ramp_rate) + on_done(True, "Operation complete", msg.data[5]) + + #----------------------------------------------------------------------- + def react_to_manual(self, manual, group, reason): + """React to Manual Mode Received from the Device + + Non-dimmable devices react immediatly when issueing a manual command + while dimmable devices slowly ramp on. This function is here to + provide DimmerBase a place to alter the default functionality. This + function should call _set_state() at the appropriate times to update + the state of the device. + + Args: + manual (on_off.Manual): The manual command type + group (int): The group sending the command + reason (str): The reason string to pass on + """ + # Need to skip over the KeypadLinc Function here + DimmerBase.react_to_manual(self, manual, group, reason) diff --git a/insteon_mqtt/device/Leak.py b/insteon_mqtt/device/Leak.py index 5498ba3a..c65f107f 100644 --- a/insteon_mqtt/device/Leak.py +++ b/insteon_mqtt/device/Leak.py @@ -5,8 +5,6 @@ #=========================================================================== from .BatterySensor import BatterySensor from .. import log -from ..Signal import Signal -from .. import message as Msg LOG = log.get_logger() @@ -28,8 +26,8 @@ class Leak(BatterySensor): only respond to the sensor when it sends out a message. The device will broadcast messages on the following groups: - group 01 = wet condition - group 02 = dry condition + group 01 = dry condition + group 02 = wet condition group 04 = heartbeat (0x11) State changes are communicated by emitting signals. Other classes can @@ -43,6 +41,8 @@ class Leak(BatterySensor): heartbeat signal. """ type_name = "leak_sensor" + GROUP_DRY = 1 + GROUP_WET = 2 def __init__(self, protocol, modem, address, name=None): """Constructor @@ -57,15 +57,12 @@ def __init__(self, protocol, modem, address, name=None): """ super().__init__(protocol, modem, address, name) - # Wet/dry signal. API: func( Device, bool is_wet ) - self.signal_wet = Signal() - # Maps Insteon groups to message type for this sensor. self.group_map = { # Dry event on group 1. - 0x01 : self.handle_dry, + 0x01 : self.handle_on_off, # Wet event on group 2. - 0x02 : self.handle_wet, + 0x02 : self.handle_on_off, # Heartbeat is on group 4 0x04 : self.handle_heartbeat, } @@ -73,42 +70,30 @@ def __init__(self, protocol, modem, address, name=None): self._is_wet = False #----------------------------------------------------------------------- - def handle_dry(self, msg): - """Handle a dry message. - - This is called by the device when a leak is cleared and the device is - dry. - - Args: - msg (InpStandard): Broadcast message from the device. - """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("LeakSensor %s broadcast ACK grp: %s", self.addr, - msg.group) - else: - LOG.info("LeakSensor %s received is-dry message", self.label) - self._set_is_wet(False) - self.update_linked_devices(msg) - - #----------------------------------------------------------------------- - def handle_wet(self, msg): - """Handle a wet message. + def _cache_state(self, group, is_on, level, reason): + """Cache the State of the Device - This is called by the device when a leak is detected and the device - is wet. + Used to help with the unique device functions. Args: - msg (InpStandard): Broadcast message from the device. + group (int): The group which this applies + is_on (bool): Whether the device is on. + level (int): The new device level in the range [0,255]. 0 is off. + reason (str): Reason string to pass around. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("LeakSensor %s broadcast ACK grp: %s", self.addr, - msg.group) + # Handle_Refresh sends level and not is_on + if is_on is None: + if level is not None: + is_on = level > 0 + else: + return # No on data? + if not is_on: + self._is_wet = False else: - LOG.info("LeakSensor %s received is-wet message", self.label) - self._set_is_wet(True) - self.update_linked_devices(msg) + if group == self.GROUP_WET: + self._is_wet = True + elif group == self.GROUP_DRY: + self._is_wet = False #----------------------------------------------------------------------- def handle_heartbeat(self, msg): @@ -123,55 +108,37 @@ def handle_heartbeat(self, msg): Args: msg (InpStandard): Broadcast message from the device. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("LeakSensor %s broadcast ACK grp: %s", self.addr, - msg.group) - else: - LOG.info("LeakSensor %s received heartbeat", self.label) - # Update the wet/dry state using the heartbeat if needed. - is_wet = msg.cmd1 == 0x13 - if self._is_wet != is_wet: - self._set_is_wet(is_wet) + LOG.info("LeakSensor %s received heartbeat", self.label) + # Update the wet/dry state using the heartbeat if needed. + is_wet = msg.cmd1 == 0x13 + if self._is_wet != is_wet: + self._set_state(group=self.GROUP_WET, is_on=is_wet) - # Send True for any heart beat message - self.signal_heartbeat.emit(self, True) - self.update_linked_devices(msg) + # Send True for any heart beat message + self.signal_heartbeat.emit(self, True) + self.update_linked_devices(msg) #----------------------------------------------------------------------- - def handle_refresh(self, msg): - """Callback for handling refresh() responses. + def refresh(self, force=False, group=None, on_done=None): + """Refresh the current device state and database if needed. - 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 - handler.DeviceRefresh when we can an ACK for the refresh command. + This sends a ping to the device. The reply has the current device + state (on/off, level, etc) and the current db delta value which is + checked against the current db value. If the current db is out of + date, it will trigger a download of the database. - NOTE: refresh() will not work if the device is asleep. + This will send out an updated signal for the current device status + whenever possible (like dimmer levels). Args: - msg (message.InpStandard): The refresh message reply. The current - device state is in the msg.cmd2 field. + 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 + device model information even if it is already known. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) """ - LOG.ui("LeakSensor %s refresh on = %s", self.addr, msg.cmd2 != 0x00) - - # Current wet/dry level is stored in cmd2. Non-zero == wet. - self._set_is_wet(msg.cmd2 != 0x00) - - #----------------------------------------------------------------------- - def _set_is_wet(self, is_wet): - """Update the device wet/dry 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. - - Args: - is_wet (bool): True if Leak is detected, False if it isn't. - """ - LOG.info("Setting device %s on:%s", self.label, is_wet) - self._is_wet = is_wet - - self.signal_wet.emit(self, self._is_wet) + # Needed to pass the GROUP_WET data to base refresh() + group = group if group is not None else self.GROUP_WET + super().refresh(force=force, group=group, on_done=on_done) #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/Motion.py b/insteon_mqtt/device/Motion.py index 2aa00299..b5ed92a5 100644 --- a/insteon_mqtt/device/Motion.py +++ b/insteon_mqtt/device/Motion.py @@ -45,10 +45,7 @@ class Motion(BatterySensor): 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_state( Device, bool is_on ): Sent when the sensor is tripped - (is_on=True) or resets (is_on=False). + the device (like sending MQTT messages). - signal_low_battery( Device, bool is_low ): Sent to indicate the current battery state. @@ -89,7 +86,6 @@ def __init__(self, protocol, modem, address, name=None): # Remote (mqtt) commands mapped to methods calls. Add to the # base class defined commands. self.cmd_map.update({ - 'set_flags' : self.set_flags, 'set_low_battery_voltage': self.set_low_battery_voltage, 'get_battery_voltage' : self._get_ext_flags, }) @@ -104,6 +100,13 @@ def __init__(self, protocol, modem, address, name=None): # requests. Otherwise, a request may get queued multiple times self._battery_request_time = 0 + # Define the flags handled by set_flags() + self.set_flags_map.update({"led_on": self.update_flags, + "night_only": self.update_flags, + "on_only": self.update_flags, + "timeout": self._set_timeout, + "light_sensitivity": self._set_light_sens}) + #----------------------------------------------------------------------- @property def battery_voltage_time(self): @@ -198,95 +201,57 @@ def handle_dawn(self, msg): msg (InpStandard): Broadcast message from the device. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("Motion %s broadcast ACK grp: %s", self.addr, - msg.group) - else: - # Send True for dawn, False for dusk. - LOG.info("Motion %s broadcast grp: %s cmd %s", self.addr, - msg.group, msg.cmd1) - self.signal_dawn.emit(self, msg.cmd1 == Msg.CmdType.ON) + # Send True for dawn, False for dusk. + LOG.info("Motion %s broadcast grp: %s cmd %s", self.addr, + msg.group, msg.cmd1) + self.signal_dawn.emit(self, msg.cmd1 == Msg.CmdType.ON) #----------------------------------------------------------------------- - def set_flags(self, on_done, **kwargs): - """Set internal device flags. - - This command is used to change internal device flags and states. - These include LED On/Off (off conserves batteries), Timeout (seconds - between state updates), Light Sensitivity (Percentage of light for - night sensitivity), Night Only Mode and On Only Mode: - - valid kwargs: - - led_on = 1/0: Should led flash on motion? - - - night_only = 1/0: Should motion only be reported at night? - - - on_only = 1/0: Should only on motions be reported? - - - timeout = seconds between state updates (The older 2842 models - allow between 30 seconds to over 4 hours in 30 second intervals, - the 2844 models allow between 10 seconds and 40 minutes in 10 - second intervals) - - - light_sensitivity = 1-255: Amount of darkness required for night - to be triggered. - - Args: - kwargs: Key=value pairs of the flags to change. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) + def update_flags(self, on_done=None, **kwargs): + """Change the operating flags. """ - LOG.info("Motion %s cmd: set operation flags", self.label) - - # Check the input flags to make sure only ones we can understand were - # passed in. - flags = set(["led_on", "night_only", "on_only", "timeout", - "light_sensitivity"]) - unknown = set(kwargs.keys()).difference(flags) - if unknown: - LOG.error("Unknown Motion flags input: %s.\n Valid flags " - "are: %s", unknown, flags) - seq = CommandSeq(self, "Motion Set Flags Success", on_done, - name="SetFlags") - - # 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 - # current bits and pass that to the callback which will update them to - # make the changes. - flags = set(["led_on", "night_only", "on_only"]) - if any(x in kwargs.keys() for x in flags): - seq.add(self._get_ext_flags) - seq.add(self._change_flags, kwargs) - if "light_sensitivity" in kwargs.keys(): - seq.add(self._set_light_sens, kwargs["light_sensitivity"]) - if "timeout" in kwargs.keys(): - seq.add(self._set_timeout, kwargs["timeout"]) - + name="UpdateFlags") + seq.add(self._get_ext_flags) + seq.add(self._change_flags, kwargs) seq.run() #----------------------------------------------------------------------- - def _change_flags(self, flags, on_done): + def _change_flags(self, flags, on_done=None): """Change the operating flags. See the set_flags() code for details. """ + # Check for valid input + if 'led_on' in flags: + led_on = util.input_bool(flags, 'led_on') + if led_on is None: + LOG.error("Invalid led on.") + on_done(False, 'Invalid led on.', None) + return + else: + led_on = self.led_on + if 'night_only' in flags: + night_only = util.input_bool(flags, 'night_only') + if night_only is None: + LOG.error("Invalid night only.") + on_done(False, 'Invalid night only.', None) + return + else: + night_only = self.night_only + if 'on_only' in flags: + on_only = util.input_bool(flags, 'on_only') + if on_only is None: + LOG.error("Invalid on only.") + on_done(False, 'Invalid on only.', None) + return + else: + on_only = self.on_only # Generate the value of the combined flags. value = 0 - value = util.bit_set(value, 3, flags.get("led_on", self.led_on)) - night_only = flags.get("night_only", None) - if night_only is None: - night_only = self.night_only - else: - night_only ^= 1 + value = util.bit_set(value, 3, led_on) value = util.bit_set(value, 2, night_only) - on_only = flags.get("on_only", None) - if on_only is None: - on_only = self.on_only - else: - on_only ^= 1 value = util.bit_set(value, 1, on_only) # Push the flags value to the device. @@ -296,8 +261,8 @@ def _change_flags(self, flags, on_done): value, # D3 = the flag value ] + [0x00] * 11) msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, - on_done) + callback = self.generic_ack_callback("Flags updated.") + msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) #----------------------------------------------------------------------- @@ -366,19 +331,17 @@ def handle_ext_flags(self, msg, on_done): on_done(True, "Operation complete", msg.data[5]) #----------------------------------------------------------------------- - def handle_ext_cmd(self, msg, on_done): - """Handle replies to the set_flags command. - Nothing to do, any NAK of failure is caught by the message handler - """ - on_done(True, "Operation complete", None) - - #----------------------------------------------------------------------- - def _set_light_sens(self, sensitivity, on_done): + def _set_light_sens(self, on_done=None, **kwargs): """Change the light sensitivity amount. See the set_flags() code for details. """ - assert 1 <= int(sensitivity) <= 255 + # Check for valid input + sensitivity = util.input_byte(kwargs, 'light_sensitivity') + if sensitivity is None: + LOG.error("Invalid light sensitivity.") + on_done(False, 'Invalid light sensitivity.', None) + return # Push the flags value to the device. data = bytes([ @@ -387,12 +350,12 @@ def _set_light_sens(self, sensitivity, on_done): int(sensitivity), # D3 = the sensitivity value ] + [0x00] * 11) msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, - on_done) + callback = self.generic_ack_callback("Light sensitivity updated.") + msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) #----------------------------------------------------------------------- - def _set_timeout(self, timeout, on_done): + def _set_timeout(self, on_done=None, **kwargs): """Change the timeout in seconds. This will automatically change the timeout requested to fit within the @@ -400,7 +363,13 @@ def _set_timeout(self, timeout, on_done): See the set_flags() code for details. """ - timeout = int(timeout) + # Check for valid input + timeout = util.input_integer(kwargs, 'timeout') + if timeout is None: + LOG.error("Invalid timeout.") + on_done(False, 'Invalid timeout.', None) + return + # The calculation of the timeout value is stored differently on the # older 2842 and the newer 2844 motion sensors. We will assume the # newer style as a default. @@ -434,8 +403,8 @@ def _set_timeout(self, timeout, on_done): timeout, # D3 = the sensitivity value ] + [0x00] * 11) msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - msg_handler = handler.StandardCmd(msg, self.handle_ext_cmd, - on_done) + callback = self.generic_ack_callback("Motion timeout updated.") + msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/Outlet.py b/insteon_mqtt/device/Outlet.py index ac6dbffa..1e69543f 100644 --- a/insteon_mqtt/device/Outlet.py +++ b/insteon_mqtt/device/Outlet.py @@ -4,20 +4,18 @@ # #=========================================================================== import functools -from .Base import Base -from . import functions +from .base import ResponderBase +from .functions import Backlight 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 LOG = log.get_logger() -class Outlet(functions.Set, Base): +class Outlet(Backlight, ResponderBase): """Insteon on/off outlet device. This is used for in-wall on/off outlets. Each outlet (top and bottom) is @@ -26,12 +24,7 @@ class Outlet(functions.Set, Base): 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_state( Device, int group, bool is_on, on_off.Mode mode, str - reason ): Sent whenever the switch is turned on or off. - Group will be 1 for the top outlet and 2 for the bottom - outlet. + the device (like sending MQTT messages). """ def __init__(self, protocol, modem, address, name=None): @@ -49,19 +42,6 @@ def __init__(self, protocol, modem, address, name=None): self._is_on = [False, False] # top outlet, bottom outlet - # Support on/off style signals. - # API: func(Device, int group, bool is_on, on_off.Mode mode, - # str reason) - self.signal_state = Signal() - - # 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_flags' : self.set_flags, - }) - # NOTE: the outlet does NOT include the group in the ACK of an on/off # command. So there is no way to tell which outlet is being ACK'ed # if we send multiple messages to it. Each time on or off is called, @@ -75,8 +55,11 @@ def __init__(self, protocol, modem, address, name=None): self.group_map.update({0x01: self.handle_on_off, 0x02: self.handle_on_off}) + # List of responder group numbers + self.responder_groups = [0x01, 0x02] + #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): + def refresh(self, force=False, group=None, on_done=None): """Refresh the current device state and database if needed. This sends a ping to the device. The reply has the current device @@ -87,10 +70,16 @@ 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). + Outlet uses a unique refresh command in order to get the state of + both outlets. + 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 device model information even if it is already known. + group (int): The group being refreshed, it is passed to + handle_refresh() so that the state signal is correct. Should + generally be None. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ @@ -136,36 +125,21 @@ def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("Outlet %s grp: %s cmd: on", group, self.addr) - assert 1 <= group <= 2 - assert isinstance(mode, on_off.Mode) - - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode - - # Send the requested on code value. - cmd1 = on_off.Mode.encode(True, mode) - - # Top outlet uses a standard message - if group == 1: - msg = Msg.OutStandard.direct(self.addr, cmd1, 0xff) - - # Bottom outlet uses an extended message - else: - data = bytes([0x02] + [0x00] * 13) - msg = Msg.OutExtended.direct(self.addr, cmd1, 0xff, data) - - # Use the standard command handler which will notify us when - # the command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - # See __init__ code comments for what this is for. self._which_outlet.append(group) - # Send the message to the PLM modem for protocol. - self.send(msg, msg_handler) + # Bottom outlet uses an extended message + if group == 2: + cmd1, cmd2 = self.cmd_on_values(mode, level, transition, group) + data = bytes([0x02] + [0x00] * 13) + msg = Msg.OutExtended.direct(self.addr, cmd1, cmd2, data) + callback = functools.partial(self.handle_ack, reason=reason) + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + else: + # Top outlet uses a regular on command pass to SetAndState + super().on(group=group, level=level, mode=mode, reason=reason, + transition=transition, on_done=on_done) #----------------------------------------------------------------------- def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", @@ -186,160 +160,24 @@ def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ - LOG.info("Outlet %s cmd: off", self.addr) - assert 1 <= group <= 2 - assert isinstance(mode, on_off.Mode) - - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode - - # Send the correct off code. - cmd1 = on_off.Mode.encode(False, mode) - - # Top outlet uses a standard message - if group == 1: - msg = Msg.OutStandard.direct(self.addr, cmd1, 0x00) - - # Bottom outlet uses an extended message - else: - data = bytes([0x02] + [0x00] * 13) - msg = Msg.OutExtended.direct(self.addr, cmd1, 0x00, data) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - # See __init__ code comments for what this is for. self._which_outlet.append(group) - # Send the message to the PLM modem for protocol. - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def set_backlight(self, level, on_done=None): - """Set the device backlight level. - - This changes the level of the LED back light that is used by the - device status LED's (dimmer levels, KeypadLinc buttons, etc). - - The default factory level is 0x1f. - - Per page 157 of insteon dev guide range is between 0x11 and 0x7F, - however in practice backlight can be incremented from 0x00 to at least - 0x7f. - - Args: - level (int): The backlight level in the range [0,255] - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - seq = CommandSeq(self, "Outlet set backlight complete", on_done, - name="SetBacklight") - - # First set the backlight on or off depending on level value - is_on = level > 0 - LOG.info("Outlet %s setting backlight to %s", self.label, is_on) - cmd = 0x09 if is_on else 0x08 - msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, bytes([0x00] * 14)) - msg_handler = handler.StandardCmd(msg, self.handle_backlight, on_done) - seq.add_msg(msg, msg_handler) - - if is_on: - # Second set the level only if on - LOG.info("Outlet %s setting backlight to %s", self.label, level) - - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x07, # D2 set global led brightness - level, # D3 brightness level - ] + [0x00] * 11) - - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - msg_handler = handler.StandardCmd(msg, self.handle_backlight, - on_done) - seq.add_msg(msg, msg_handler) - - seq.run() - - #----------------------------------------------------------------------- - def set_flags(self, on_done, **kwargs): - """Set internal device flags. - - This command is used to change internal device flags and states. - Valid inputs are: - - - backlight=level: Change the backlight LED level (0-255). See - set_backlight() for details. - - Args: - kwargs: Key=value pairs of the flags to change. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Outlet %s cmd: set flags", self.label) - - # Check the input flags to make sure only ones we can understand were - # passed in. - FLAG_BACKLIGHT = "backlight" - flags = set([FLAG_BACKLIGHT]) - unknown = set(kwargs.keys()).difference(flags) - if unknown: - LOG.error("Unknown Outlet flags input: %s.\n Valid flags " - "are: %s", unknown, flags) - - # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self, "Outlet set_flags complete", on_done, - name="DevSetFlags") - - if FLAG_BACKLIGHT in kwargs: - backlight = util.input_byte(kwargs, FLAG_BACKLIGHT) - seq.add(self.set_backlight, backlight) - - seq.run() - - #----------------------------------------------------------------------- - def handle_backlight(self, msg, on_done): - """Callback for handling set_backlight() responses. - - This is called when we get a response to the set_backlight() command. - We don't need to do anything - just call the on_done callback with - the status. - - Args: - msg (InpStandard): The response message from the command. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - on_done(True, "Backlight level updated", None) - - #----------------------------------------------------------------------- - def handle_on_off(self, msg): - """Handle broadcast on_off messages from this device. - - This is called via the handle_broadcast and the mapping in group_map. - - Args: - msg (InpStandard): Broadcast message from the device. - """ - reason = on_off.REASON_DEVICE - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("Outlet %s broadcast ACK grp: %s", self.addr, msg.group) - - # On/off command codes. - elif on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - LOG.info("Outlet %s broadcast grp: %s on: %s mode: %s", self.addr, - msg.group, is_on, mode) - - self._set_is_on(msg.group, is_on, mode, reason) - - self.update_linked_devices(msg) + # Bottom outlet uses an extended message + if group == 2: + cmd1, cmd2 = self.cmd_off_values(mode, transition, group) + data = bytes([0x02] + [0x00] * 13) + msg = Msg.OutExtended.direct(self.addr, cmd1, cmd2, data) + callback = functools.partial(self.handle_ack, reason=reason) + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + else: + # Top outlet uses a regular on command pass to SetAndState + super().off(group=group, mode=mode, reason=reason, + transition=transition, on_done=on_done) #----------------------------------------------------------------------- - def handle_refresh(self, msg): + def handle_refresh(self, msg, group=None): """Callback for handling refresh() responses. This is called when we get a response to the refresh() command. The @@ -367,116 +205,76 @@ def handle_refresh(self, msg): is_on[1]) # Set the state for each outlet. - self._set_is_on(1, is_on[0], reason=on_off.REASON_REFRESH) - self._set_is_on(2, is_on[1], reason=on_off.REASON_REFRESH) + self._set_state(group=1, is_on=is_on[0], + reason=on_off.REASON_REFRESH) + self._set_state(group=2, is_on=is_on[1], + reason=on_off.REASON_REFRESH) else: LOG.error("Outlet %s unknown refresh response %s", self.label, msg.cmd2) #----------------------------------------------------------------------- - def handle_ack(self, msg, on_done, reason=""): + def decode_on_level(self, cmd1, cmd2): """Callback for standard commanded messages. - This callback is run when we get a reply back from one of our - 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. + Decodes the cmds recevied from the device into is_on, level, and mode + to be consumed by _set_state(). 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) - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. + cmd1 (byte): The command 1 value + cmd2 (byte): The command 2 value + Returns: + is_on (bool): Is the device on. + mode (on_off.Mode): The type of command to send (normal, fast, etc). + level (int): On level between 0-255. + group (int): The group number that this state applies to. Defaults + to None. """ + # Default Returns + group = None + is_on = None + level = None + mode = on_off.Mode.NORMAL + # Get the last outlet we were commanding. The message doesn't tell # us which outlet it was so we have to track it here. See __init__ # code comments for more info. if not self._which_outlet: LOG.error("Outlet %s ACK error. No outlet ID's were saved", self.addr) - on_done(False, "Outlet update failed - no ID's saved", None) - return - - group = self._which_outlet.pop(0) - - # If this it the ACK we're expecting, update the internal - # state and emit our signals. - LOG.debug("Outlet %s grp: %s ACK: %s", self.addr, group, msg) - - is_on, mode = on_off.Mode.decode(msg.cmd1) - reason = reason if reason else on_off.REASON_COMMAND - self._set_is_on(group, is_on, mode, reason) - on_done(True, "Outlet state updated to on=%s" % self._is_on, - self._is_on) + else: + group = self._which_outlet.pop(0) + is_on, mode = on_off.Mode.decode(cmd1) + return (is_on, level, mode, group) #----------------------------------------------------------------------- - def handle_group_cmd(self, addr, msg): - """Respond to a group command for this device. + def group_cmd_local_group(self, entry): + """Get the Local Group Affected by this Group Command - This is called when this device is a responder to a scene. The - device that received the broadcast message (handle_broadcast) will - call this method for every device that is linked to it. The device - should look up the responder entry for the group in it's all link - database and update it's state accordingly. + For most devices this is group 1, but for multigroup devices such + as the KPL, they may need to decode the local group from the + entry data. Args: - addr (Address): The device that sent the message. This is the - controller in the scene. - msg (InpStandard): Broadcast message from the device. Use - msg.group to find the group and msg.cmd1 for the command. + entry (DeviceEntry): The local db entry for this group command. + Returns: + group (int): The local group affected """ - # Make sure we're really a responder to this message. This shouldn't - # ever occur. - 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, - msg.group, addr) - return - - # The local button being modified is stored in the db entry. - localGroup = entry.data[2] - - # Handle on/off commands codes. - if on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - self._set_is_on(localGroup, is_on, mode, on_off.REASON_SCENE) - - # Note: I don't believe the on/off switch can participate in manual - # mode stopping commands since it changes state when the button is - # held, not when it's released. - else: - LOG.warning("Outlet %s unknown group cmd %#04x", self.addr, - msg.cmd1) + return entry.data[2] #----------------------------------------------------------------------- - def _set_is_on(self, group, is_on, mode=on_off.Mode.NORMAL, reason=""): - """Update the device on/off state. + def _cache_state(self, group, is_on, level, reason): + """Cache the State of the Device - 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. + Used to help with the unique device functions. Args: - group (int): The group to update (1 for upper outlet, 2 for lower). - is_on (bool): True if the switch is on, False if it isn't. - mode (on_off.Mode): The type of on/off that was triggered (normal, - fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. + group (int): The group which this applies + is_on (bool): Whether the device is on. + level (int): The new device level in the range [0,255]. 0 is off. + reason (str): Reason string to pass around. """ - is_on = bool(is_on) - - LOG.info("Setting device %s grp: %s on %s %s %s", self.label, group, - is_on, mode, reason) self._is_on[group - 1] = is_on - # Notify others that the outlet state has changed. - self.signal_state.emit(self, button=group, is_on=is_on, mode=mode, - reason=reason) - #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/Remote.py b/insteon_mqtt/device/Remote.py index fcb3bf52..5c83cf44 100644 --- a/insteon_mqtt/device/Remote.py +++ b/insteon_mqtt/device/Remote.py @@ -5,17 +5,16 @@ #=========================================================================== import time from .BatterySensor import BatterySensor +from .functions import ManualCtrl from .. import log -from .. import on_off from .. import message as Msg from .. import handler -from ..Signal import Signal from .. import util LOG = log.get_logger() -class Remote(BatterySensor): +class Remote(ManualCtrl, 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 @@ -31,14 +30,6 @@ class Remote(BatterySensor): 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_state( Device, int group, bool is_on, on_off.Mode mode ): - Sent whenever a button is pressed. The remote will toggle on and off - with each button press. - - - signal_manual( Device, int group, on_off.Manual mode ): Sent when the - device starts or stops manual mode (when a button is held down or - released). """ # This defines what is the minimum time between battery status requests # for devices that support it. Value is in seconds @@ -74,15 +65,7 @@ def __init__(self, protocol, modem, address, name, num_button): # 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_state = Signal() - - # Manual mode start up, down, off - # API: func(Device, int group, on_off.Manual mode) - self.signal_manual = Signal() + self.group_map[i] = self.handle_on_off self.cmd_map.update({ 'get_battery_voltage' : self.get_extended_flags, @@ -136,41 +119,6 @@ def handle_extended_flags(self, msg, on_done): batt_volt <= self.BATTERY_VOLTAGE_LOW) on_done(True, "Battery voltage is %s" % batt_volt, msg.data[9]) - #----------------------------------------------------------------------- - def handle_button(self, msg): - """Handle button presses and hold downs - - 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 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("BatterySensor %s broadcast ACK grp: %s", self.addr, - msg.group) - else: - # On/off command codes. - 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) - - # Notify others that the button was pressed. - self.signal_state.emit(self, button=msg.group, is_on=is_on, - mode=mode) - self.update_linked_devices(msg) - - # 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) - - self.signal_manual.emit(self, button=msg.group, manual=manual) - self.update_linked_devices(msg) - #----------------------------------------------------------------------- def link_data(self, is_controller, group, data=None): """Create default device 3 byte link data. @@ -212,51 +160,6 @@ def link_data(self, is_controller, group, data=None): # For each field, use the input if not -1, else the default. return util.resolve_data3(defaults, data) - #----------------------------------------------------------------------- - 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]}] - 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'] - return [data_1, data_2, data_3] - #----------------------------------------------------------------------- def get_extended_flags(self, on_done): """Requests the Extended Flags from the Device diff --git a/insteon_mqtt/device/SmokeBridge.py b/insteon_mqtt/device/SmokeBridge.py index f088d10a..f2c50d93 100644 --- a/insteon_mqtt/device/SmokeBridge.py +++ b/insteon_mqtt/device/SmokeBridge.py @@ -4,7 +4,7 @@ # #=========================================================================== import enum -from .Base import Base +from .base import Base from ..CommandSeq import CommandSeq from .. import log from .. import message as Msg @@ -78,7 +78,7 @@ def __init__(self, protocol, modem, address, name=None): }) #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): + def refresh(self, force=False, group=None, on_done=None): """Refresh the current device state and database if needed. This sends a ping to the device. Smoke bridge can't report it's @@ -86,10 +86,15 @@ def refresh(self, force=False, on_done=None): check against our current db. If the current db is out of date, it will trigger a download of the database. + Smokebridge uses a unique refresh command. + 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 device model information even if it is already known. + group (int): The group being refreshed, it is passed to + handle_refresh() so that the state signal is correct. Should + generally be None. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ @@ -120,15 +125,10 @@ def handle_message(self, msg): Args: msg (InpStandard): Broadcast message from the device. """ - # ACK of the broadcast - ignore this. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("Smoke bridge %s broadcast ACK grp: %s", self.addr, - msg.group) - # 0x11 ON command for the smoke bridge means the error is active. # NOTE: there is no off command - that seems to be handled by the # bridge sending the CLEAR condition group. - elif msg.cmd1 == Msg.CmdType.ON: + if msg.cmd1 == Msg.CmdType.ON: LOG.info("Smoke bridge %s broadcast ON grp: %s", self.addr, msg.group) diff --git a/insteon_mqtt/device/Switch.py b/insteon_mqtt/device/Switch.py index a7a6b903..b963186b 100644 --- a/insteon_mqtt/device/Switch.py +++ b/insteon_mqtt/device/Switch.py @@ -3,22 +3,15 @@ # Insteon on/off device # #=========================================================================== -import functools -from .Base import Base -from . import functions -from ..CommandSeq import CommandSeq -from .. import handler +from .base import ResponderBase +from .functions import Scene, Backlight, ManualCtrl from .. import log -from .. import message as Msg -from .. import on_off -from ..Signal import Signal -from .. import util LOG = log.get_logger() #=========================================================================== -class Switch(functions.Set, functions.Scene, Base): +class Switch(Scene, Backlight, ManualCtrl, ResponderBase): """Insteon on/off switch device. This class can be used to model any device that acts like a on/off switch @@ -26,13 +19,7 @@ class Switch(functions.Set, functions.Scene, Base): 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_state( Device, bool is_on, on_off.Mode mode, str reason ): - Sent whenever the switch 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). + the device (like sending MQTT messages). """ def __init__(self, protocol, modem, address, name=None): """Constructor @@ -47,373 +34,8 @@ def __init__(self, protocol, modem, address, name=None): """ super().__init__(protocol, modem, address, name) - self._is_on = False - - # Support on/off style signals. - # API: func(Device, bool is_on, on_off.Mode mode, str reason) - self.signal_state = 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({ - 'on' : self.on, - 'off' : self.off, - 'set_flags' : self.set_flags, - }) - # Update the group map with the groups to be paired and the handler # for broadcast messages from this group self.group_map.update({0x01: self.handle_on_off}) #----------------------------------------------------------------------- - def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the device on. - - NOTE: This does NOT simulate a button press on the device - it just - changes the state of the device. It will not trigger any responders - that are linked to this device. To simulate a button press, call the - scene() method. - - This 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 - the state changed signals. - - Args: - group (int): The group to send the command to. For switches this - this must be 1. Allowing a group here gives us a consistent - API to the on command across devices. - level (int): If non-zero, turn the device on. The API is an int - to keep a consistent API with other devices. - mode (on_off.Mode): The type of command to send (normal, fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Switch %s cmd: on %s", self.addr, mode) - assert group == 0x01 - assert isinstance(mode, on_off.Mode) - - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode - - # Send the requested on code value. - cmd1 = on_off.Mode.encode(True, mode) - msg = Msg.OutStandard.direct(self.addr, cmd1, 0xff) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", - transition=None, on_done=None): - """Turn the device 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. - - 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 - the state changed signals. - - Args: - group (int): The group to send the command to. For this device, - this must be 1. Allowing a group here gives us a consistent - API to the on command across devices. - mode (on_off.Mode): The type of command to send (normal, fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Switch %s cmd: off %s", self.addr, mode) - assert group == 0x01 - assert isinstance(mode, on_off.Mode) - - if transition or mode == on_off.Mode.RAMP: - LOG.error("Device %s does not support transition.", self.addr) - mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode - - # Send an off or instant off command. - cmd1 = on_off.Mode.encode(False, mode) - msg = Msg.OutStandard.direct(self.addr, cmd1, 0x00) - - # Use the standard command handler which will notify us when the - # command is ACK'ed. - callback = functools.partial(self.handle_ack, reason=reason) - msg_handler = handler.StandardCmd(msg, callback, on_done) - self.send(msg, msg_handler) - - #----------------------------------------------------------------------- - def set_backlight(self, level, on_done=None): - """Set the device backlight level. - - This changes the level of the LED back light that is used by the - device status LED's (dimmer levels, KeypadLinc buttons, etc). - - The default factory level is 0x1f. - - Per page 157 of insteon dev guide range is between 0x11 and 0x7F, - however in practice backlight can be incremented from 0x00 to at least - 0x7f. - - Args: - level (int): The backlight level in the range [0,255] - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - seq = CommandSeq(self, "Switch set backlight complete", on_done, - name="SetBacklight") - - # First set the backlight on or off depending on level value - is_on = level > 0 - LOG.info("Switch %s setting backlight to %s", self.label, is_on) - cmd = 0x09 if is_on else 0x08 - msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, bytes([0x00] * 14)) - msg_handler = handler.StandardCmd(msg, self.handle_backlight, on_done) - seq.add_msg(msg, msg_handler) - - if is_on: - # Second set the level only if on - LOG.info("Switch %s setting backlight to %s", self.label, level) - - # Extended message data - see Insteon dev guide p156. - data = bytes([ - 0x01, # D1 must be group 0x01 - 0x07, # D2 set global led brightness - level, # D3 brightness level - ] + [0x00] * 11) - - msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) - msg_handler = handler.StandardCmd(msg, self.handle_backlight, - on_done) - seq.add_msg(msg, msg_handler) - - seq.run() - - #----------------------------------------------------------------------- - def set_flags(self, on_done, **kwargs): - """Set internal device flags. - - This command is used to change internal device flags and states. - Valid inputs are: - - - on_level=level: Change the default device on level (0-255) See - set_on_level for details. - - Args: - kwargs: Key=value pairs of the flags to change. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - LOG.info("Switch %s cmd: set flags", self.label) - - # Check the input flags to make sure only ones we can understand were - # passed in. - FLAG_BACKLIGHT = "backlight" - flags = set([FLAG_BACKLIGHT]) - unknown = set(kwargs.keys()).difference(flags) - if unknown: - LOG.error("Unknown Switch flags input: %s.\n Valid flags " - "are: %s", unknown, flags) - - # Start a command sequence so we can call the flag methods in series. - seq = CommandSeq(self, "Switch set_flags complete", on_done, - name="DevSetFlags") - - if FLAG_BACKLIGHT in kwargs: - backlight = util.input_byte(kwargs, FLAG_BACKLIGHT) - seq.add(self.set_backlight, backlight) - - seq.run() - - #----------------------------------------------------------------------- - def handle_backlight(self, msg, on_done): - """Callback for handling set_backlight() responses. - - This is called when we get a response to the set_backlight() command. - We don't need to do anything - just call the on_done callback with - the status. - - Args: - msg (InpStandard): The response message from the command. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - on_done(True, "Backlight level updated", None) - - #----------------------------------------------------------------------- - def handle_on_off(self, msg): - """Handle broadcast messages from this device. - - This is called from base.handle_broadcast using the group_cmd map. - - Args: - msg (InpStandard): Broadcast message from the device. - """ - # If we have a saved reason from a simulated scene command, use that. - # Otherwise the device button was pressed. - reason = self.broadcast_reason if self.broadcast_reason else \ - on_off.REASON_DEVICE - self.broadcast_reason = "" - - # ACK of the broadcast. Ignore this unless we sent a simulated off - # scene in which case run the broadcast done handler. This is a - # weird special case - see scene() for details. - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("Switch %s broadcast ACK grp: %s", self.addr, msg.group) - return - - # On/off command codes. - elif on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - LOG.info("Switch %s broadcast grp: %s on: %s mode: %s", self.addr, - msg.group, is_on, mode) - - # For an on command, we can update directly. - if is_on: - self._set_is_on(True, mode, reason) - - else: - self._set_is_on(False, mode, reason) - - # Starting or stopping manual mode. - 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=manual) - - # Switches change state when the switch is held (not all devices - # do this). - if manual == on_off.Manual.UP: - self._set_is_on(True, on_off.Mode.MANUAL, reason) - elif manual == on_off.Manual.DOWN: - self._set_is_on(False, on_off.Mode.MANUAL, reason) - - # 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). - self.update_linked_devices(msg) - - #----------------------------------------------------------------------- - def handle_refresh(self, msg): - """Callback for handling refresh() responses. - - This is called when we get a response to the refresh() command. The - refresh command reply will contain the current device state in cmd2 - and this updates the device with that value. It is called by - 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. - """ - LOG.ui("Switch %s refresh on=%s", self.label, msg.cmd2 > 0x00) - - # Current on/off level is stored in cmd2 so update our level. - self._set_is_on(msg.cmd2 > 0x00, reason=on_off.REASON_REFRESH) - - #----------------------------------------------------------------------- - def handle_ack(self, msg, on_done, reason=""): - """Callback for standard commanded messages. - - This callback is run when we get a reply back from one of our - 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. - - 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) - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - """ - # If this it the ACK we're expecting, update the internal state and - # emit our signals. - LOG.debug("Switch %s ACK: %s", self.addr, msg) - - is_on, mode = on_off.Mode.decode(msg.cmd1) - reason = reason if reason else on_off.REASON_COMMAND - self._set_is_on(is_on, mode, reason) - on_done(True, "Switch state updated to on=%s" % self._is_on, - self._is_on) - - #----------------------------------------------------------------------- - 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 - device that received the broadcast message (handle_broadcast) will - call this method for every device that is linked to it. The device - should look up the responder entry for the group in it's all link - database and update it's state accordingly. - - Args: - addr (Address): The device that sent the message. This is the - controller in the scene. - msg (InpStandard): 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, msg.group, is_controller=False) - if not entry: - LOG.error("Switch %s has no group %s entry from %s", self.addr, - msg.group, addr) - return - - # Handle on/off commands codes. - if on_off.Mode.is_valid(msg.cmd1): - is_on, mode = on_off.Mode.decode(msg.cmd1) - self._set_is_on(is_on, mode, on_off.REASON_SCENE) - - # Note: I don't believe the on/off switch can participate in manual - # mode stopping commands since it changes state when the button is - # held, not when it's released. - else: - LOG.warning("Switch %s unknown group cmd %#04x", self.addr, - msg.cmd1) - - #----------------------------------------------------------------------- - def _set_is_on(self, is_on, mode=on_off.Mode.NORMAL, reason=""): - """Update the device 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. - - Args: - is_on (bool): True if the switch is on, False if it isn't. - mode (on_off.Mode): The type of on/off that was triggered (normal, - fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - """ - LOG.info("Setting device %s on %s %s %s", self.label, is_on, - mode, reason) - self._is_on = bool(is_on) - - self.signal_state.emit(self, is_on=self._is_on, mode=mode, - reason=reason) - - #----------------------------------------------------------------------- diff --git a/insteon_mqtt/device/Thermostat.py b/insteon_mqtt/device/Thermostat.py index bf3f7b6a..16ac6556 100644 --- a/insteon_mqtt/device/Thermostat.py +++ b/insteon_mqtt/device/Thermostat.py @@ -4,7 +4,7 @@ # #=========================================================================== import enum -from .Base import Base +from .base import Base from ..CommandSeq import CommandSeq from .. import log from .. import message as Msg @@ -181,50 +181,26 @@ def pair(self, on_done=None): seq.run() #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): - """Refresh the current device state and database if needed. + def addRefreshData(self, seq, force=False): + """Add commands to refresh any internal data required. - This sends a ping to the device. The reply has the current device - state (on/off, level, etc) and the current db delta value which is - checked against the current db value. If the current db is out of - date, it will trigger a download of the database. + The base class uses this update the device catalog ID's and firmware + if we don't know what they are. - This will send out an updated signal for the current device status - whenever possible. + This is split out of refresh() so derived classes that override + refresh can also get this information. - In addition, this also runs the 'get_status' command as well, which - asks the thermostat for the current state of its attributes as well - the current units selected on the device. If you are seeing errors - in temperatures that look like C and F are reversed, running a refresh - may fix the issue. + The base refresh only checks the DB value for a thermostat, all other + states are determined by get_status() Args: + seq (CommandSeq): The command sequence to add the command to. 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 device model information even if it is already known. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) """ - LOG.info("Device %s cmd: fan status refresh", self.addr) - - seq = CommandSeq(self, "Refresh complete", on_done, name="DevRefresh") - - # 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 - # database delta field. Pass skip_db here - we'll let the dimmer - # refresh handler above take care of getting the database updated. - # Otherwise this handler and the one created in the dimmer refresh - # would download the database twice. - msg = Msg.OutStandard.direct(self.addr, 0x19, 0x03) - msg_handler = handler.DeviceRefresh(self, self.handle_refresh, - force=False, num_retry=3, - skip_db=True) - seq.add_msg(msg, msg_handler) - - # If we get the FAN state correctly, then have the dimmer also get - # it's state and update the database if necessary. seq.add(self.get_status) - seq.run() + super().addRefreshData(seq, force=force) #----------------------------------------------------------------------- def get_status(self, on_done=None): @@ -419,8 +395,8 @@ def enable_broadcast(self, on_done=None): """ msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, bytes([0x00] + [0x08] + [0x00] * 12)) - msg_handler = handler.StandardCmd(msg, self.handle_generic_ack, - on_done, num_retry=3) + callback = self.generic_ack_callback("Thermostate broadcast enabled") + msg_handler = handler.StandardCmd(msg, callback, on_done, num_retry=3) self.send(msg, msg_handler) #----------------------------------------------------------------------- @@ -436,11 +412,7 @@ def handle_message(self, msg): Args: msg (InpStandard): Broadcast message from the device. """ - if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: - LOG.info("Thermostat %s broadcast ACK grp: %s", self.addr, - msg.group) - return - elif msg.cmd1 in [Msg.CmdType.ON, Msg.CmdType.OFF]: + if msg.cmd1 in [Msg.CmdType.ON, Msg.CmdType.OFF]: LOG.info("Thermostat %s broadcast %s grp: %s", self.addr, msg.cmd1, msg.group) diff --git a/insteon_mqtt/device/__init__.py b/insteon_mqtt/device/__init__.py index 132c0eda..fb9ba320 100644 --- a/insteon_mqtt/device/__init__.py +++ b/insteon_mqtt/device/__init__.py @@ -26,13 +26,14 @@ #=========================================================================== -from .Base import Base from .BatterySensor import BatterySensor from .Dimmer import Dimmer from .EZIO4O import EZIO4O from .FanLinc import FanLinc +from .HiddenDoor import HiddenDoor from .IOLinc import IOLinc from .KeypadLinc import KeypadLinc +from .KeypadLincDimmer import KeypadLincDimmer from .Leak import Leak from .MsgHistory import MsgHistory from .Motion import Motion diff --git a/insteon_mqtt/device/Base.py b/insteon_mqtt/device/base/Base.py similarity index 84% rename from insteon_mqtt/device/Base.py rename to insteon_mqtt/device/base/Base.py index d0c0ce99..71b7fbe7 100644 --- a/insteon_mqtt/device/Base.py +++ b/insteon_mqtt/device/base/Base.py @@ -4,15 +4,18 @@ # #=========================================================================== import json +import functools import os.path -from .MsgHistory import MsgHistory -from ..Address import Address -from ..CommandSeq import CommandSeq -from .. import db -from .. import handler -from .. import log -from .. import message as Msg -from .. import util +from ..MsgHistory import MsgHistory +from ...Address import Address +from ...CommandSeq import CommandSeq +from ...Signal import Signal +from ... import db +from ... import handler +from ... import log +from ... import message as Msg +from ... import util +from ... import on_off LOG = log.get_logger() @@ -114,6 +117,18 @@ def __init__(self, protocol, modem, address, name=None): # Config db is initiated by Scenes self.db_config = None + # Special callback to run when receiving a broadcast clean up. See + # scene() for details. + self.broadcast_reason = "" + + # Used for internally tracking the device state + self._is_on = False + self._level = 0x00 + + # Support dimmer style signals and motion on/off style signals. + # API: func(Device, int level, on_off.Mode mode, str reason) + self.signal_state = Signal() + # Map (mqtt) commands mapped to methods calls. These are handled in # run_command(). Derived classes can add more commands to the dict # to expand the list. Commands should all be lower case (inputs are @@ -128,6 +143,7 @@ def __init__(self, protocol, modem, address, name=None): 'linking' : self.linking, 'join': self.join, 'pair' : self.pair, + 'set_flags' : self.set_flags, 'get_flags' : self.get_flags, 'get_engine' : self.get_engine, 'get_model' : self.get_model, @@ -148,6 +164,16 @@ def __init__(self, protocol, modem, address, name=None): # not need to be paired self.group_map = {} + # Define the flags handled by set_flags() + # Keys are the flag names in lower case. The value should be the + # function to call. The signature of the function is + # function(on_done=None, **kwargs). Each function will receive all + # flags specified in the call and should just ignore those that are + # unrelated. If the value None is used, no function will be called if + # that key is the only one passed. Functions will only be called once + # even if the same function is used for multiple flags + self.set_flags_map = {} + #----------------------------------------------------------------------- def clear_db_config(self): """Clears and initializes the device config database @@ -342,7 +368,8 @@ def linking(self, group=0x01, on_done=None): # see, there is no way to cancel it. msg = Msg.OutExtended.direct(self.addr, 0x09, group, bytes([0x00] * 14)) - msg_handler = handler.StandardCmd(msg, self.handle_linking, on_done) + callback = self.generic_ack_callback("Entered linking mode") + msg_handler = handler.StandardCmd(msg, callback, on_done) self.send(msg, msg_handler) #----------------------------------------------------------------------- @@ -384,7 +411,50 @@ def pair(self, on_done=None): seq.run() #----------------------------------------------------------------------- - def refresh(self, force=False, on_done=None): + def set_flags(self, on_done, **kwargs): + """Set internal device flags. + + This command is used to change internal device flags and states. + Valid inputs are: + + Args: + kwargs: Key=value pairs of the flags to change. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("Device %s cmd: set flags", self.label) + + # force user input flags to lower + kwargs = {k.lower(): v for k, v in kwargs.items()} + + # Check the input flags to make sure only ones we can understand were + # passed in. + flags = set(self.set_flags_map.keys()) + unknown = set(kwargs.keys()).difference(flags) + if unknown: + LOG.error("Unknown set flags input: %s.\n Valid flags " + "are: %s", unknown, flags) + + # Remove Unknowns + for bad_key in unknown: + del kwargs[bad_key] + + # Start a command sequence so we can call the flag methods in series. + seq = CommandSeq(self, "Device set_flags complete", on_done, + name="DevSetFlags") + + # Use a set, so that functions are unique and only called once + functions = set() + for flag in kwargs: + if self.set_flags_map[flag] is not None: + functions.add(self.set_flags_map[flag]) + for function in functions: + seq.add(function, **kwargs) + + seq.run() + + #----------------------------------------------------------------------- + def refresh(self, force=False, group=None, on_done=None): """Refresh the current device state and database if needed. This sends a ping to the device. The reply has the current device @@ -399,6 +469,9 @@ def refresh(self, force=False, on_done=None): 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 device model information even if it is already known. + group (int): The group being refreshed, it is passed to + handle_refresh() so that the state signal is correct. Should + generally be None. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ @@ -413,7 +486,8 @@ def refresh(self, force=False, on_done=None): # 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, 0x19, 0x00) - msg_handler = handler.DeviceRefresh(self, self.handle_refresh, force, + callback = functools.partial(self.handle_refresh, group=group) + msg_handler = handler.DeviceRefresh(self, callback, force, None, num_retry=3) seq.add_msg(msg, msg_handler) @@ -894,15 +968,9 @@ def link_data_from_pretty(self, is_controller, data): list[3]: List of Data1-3 values """ # For the base devices this does nothing - 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'] + data_1 = data.get('data_1', None) + data_2 = data.get('data_2', None) + data_3 = data.get('data_3', None) return [data_1, data_2, data_3] #----------------------------------------------------------------------- @@ -942,6 +1010,49 @@ class docs for a list of valid commands. LOG.exception("Invalid command inputs to device %s'. Input cmd " "%s with args: %s", self.label, cmd, str(kwargs)) + #----------------------------------------------------------------------- + def _set_state(self, is_on=None, level=None, group=None, + mode=on_off.Mode.NORMAL, reason=""): + """Update the device level or 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. + + Args: + is_on (bool): True if the switch is on, False if it isn't. + level (int): The new device level in the range [0,255]. 0 is off. + group (int): The group to which this applies + mode (on_off.Mode): The type of on/off that was triggered (normal, + fast, etc). + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + """ + LOG.info("Setting device %s on %s level %s %s %s", self.label, is_on, + level, mode, reason) + self._cache_state(group, is_on, level, reason) + + self.signal_state.emit(self, is_on=is_on, level=level, mode=mode, + button=group, reason=reason) + + #----------------------------------------------------------------------- + def _cache_state(self, group, is_on, level, reason): + """Cache the State of the Device + + Used to help with the unique device functions. + + Args: + group (int): The group which this applies + is_on (bool): Whether the device is on. + level (int): The new device level in the range [0,255]. 0 is off. + reason (str): Reason string to pass around. + """ + if is_on is not None: + self._is_on = is_on + if level is not None: + self._level = level + #----------------------------------------------------------------------- def handle_received(self, msg): """Receives incoming message notifications from protocol @@ -962,19 +1073,23 @@ def handle_received(self, msg): self.history.add(msg) #----------------------------------------------------------------------- - def handle_refresh(self, msg): - """Handle replies to the refresh command. + def handle_refresh(self, msg, group=None): + """Callback for handling refresh() responses. - The refresh command reply will contain the current device state in - cmd2 and this updates the device with that value. + 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 + handler.DeviceRefresh when we can an ACK for the refresh command. Args: - msg (message.InpStandard): The refresh message reply. The current + msg (message.InpStandard): The refresh message reply. The current device state is in the msg.cmd2 field. """ - # Do nothing - derived types can override this if they have - # state to extract and update. - pass + LOG.ui("Device %s refresh cmd2 %s", self.addr, msg.cmd2) + + # Level works for most things can add a derive state if needed. + self._set_state(is_on=(msg.cmd2 != 0x00), group=group, + reason=on_off.REASON_REFRESH) #----------------------------------------------------------------------- def handle_flags(self, msg, on_done): @@ -1054,10 +1169,9 @@ def handle_broadcast(self, msg): The broadcast message from a device is sent when the device is triggered. The message has the group ID in it, this uses the class attribute group_map to map the group to the handler that should process - the message. This handler should be prepared to receive both broadcast - messages and CmdType.LINK_CLEANUP_REPORT messages as well. The handler - should likely pass all valid broadcast commands to - update_linked_devices() so that the associated devices can be updated. + the message. The handler should likely pass all valid broadcast + commands to update_linked_devices() so that the associated devices can + be updated. Args: msg (InpStandard): Broadcast message from the device. @@ -1071,7 +1185,106 @@ def handle_broadcast(self, msg): self.label, msg.group) #----------------------------------------------------------------------- - def handle_generic_ack(self, msg, on_done=None): + def handle_on_off(self, msg): + """Handle broadcast messages from this device. + + This can be called from base.handle_broadcast using the group_map. + + Args: + msg (InpStandard): Broadcast message from the device. + """ + # If we have a saved reason from a simulated scene command, use that. + # Otherwise the device button was pressed. + reason = self.broadcast_reason if self.broadcast_reason else \ + on_off.REASON_DEVICE + self.broadcast_reason = "" + + # On/off command codes. + if on_off.Mode.is_valid(msg.cmd1): + is_on, mode = on_off.Mode.decode(msg.cmd1) + LOG.info("Device %s broadcast grp: %s on: %s mode: %s", self.addr, + msg.group, is_on, mode) + + if is_on: + level = self.derive_on_level(mode) + self._set_state(is_on=True, level=level, mode=mode, + group=msg.group, reason=reason) + else: + level = self.derive_off_level(mode) + self._set_state(is_on=False, level=level, mode=mode, + group=msg.group, reason=reason) + + # Starting or stopping manual mode. + elif on_off.Manual.is_valid(msg.cmd1): + self.process_manual(msg, reason) + + # 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). + self.update_linked_devices(msg) + + #----------------------------------------------------------------------- + def derive_on_level(self, mode): + """Calculates the device on level based on the mode and the local + on_level set in the flags. + + For the base class, this always returns a level of None. Other + classes, such as a Dimmer, may alter this return value. + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + Returns: + level (int) + """ + level = None + return level + + #----------------------------------------------------------------------- + def derive_off_level(self, mode): + """Calculates the device off level based on the mode and the local + on_level set in the flags. + + For the base class, this always returns a level of None. Other + classes, such as a Dimmer, may alter this return value. + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + Returns: + level (int) + """ + level = None + return level + + #----------------------------------------------------------------------- + def process_manual(self, msg, reason): + """Handle Manual Mode Received from the Device + + This is called as part of the handle_broadcast response. It + processes the manual mode changes sent by the device. + + The Base class does nothing with this, classes that extend this + should add the necessary functionality here. + + Args: + msg (InpStandard): Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. + reason (str): The reason string to pass on + """ + pass + + #----------------------------------------------------------------------- + def generic_ack_callback(self, text): + """Creates a handle_generic_ack callback with unique on_done text + + Args: + text (str): The string to output to the on_done callback when on + success + """ + return functools.partial(self.handle_generic_ack, text=text) + + #----------------------------------------------------------------------- + def handle_generic_ack(self, msg, on_done=None, text=None): """Handles generic ack responses where there is nothing to do. Used where there is nothing to do on receiving an ack except call @@ -1084,8 +1297,9 @@ def handle_generic_ack(self, msg, on_done=None): """ on_done = util.make_callback(on_done) - LOG.debug("Device %s generic ack recevied", self.addr) - on_done(True, "Device generic ack recevied", None) + LOG.debug("Device %s ack recevied", self.addr) + text = "Device acknowledged the command." if text is None else text + on_done(True, text, None) #----------------------------------------------------------------------- def update_linked_devices(self, msg): @@ -1138,21 +1352,6 @@ def handle_group_cmd(self, addr, msg): # Default implementation - derived classes should specialize this. LOG.info("Device %s ignoring group cmd - not implemented", self.label) - #----------------------------------------------------------------------- - def handle_linking(self, msg, on_done=None): - """Respond to a linking command for this device. - - This is called when we get a response to the linking command. It - will trigger on_done with either a success or failure flag set. - - Args: - msg (InpStandard): The linking response message. - 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) - on_done(True, "Entered linking mode", None) - #----------------------------------------------------------------------- def _db_update(self, local_group, is_controller, remote_addr, remote_group, two_way, refresh, on_done, local_data, remote_data): diff --git a/insteon_mqtt/device/base/DimmerBase.py b/insteon_mqtt/device/base/DimmerBase.py new file mode 100644 index 00000000..547c8435 --- /dev/null +++ b/insteon_mqtt/device/base/DimmerBase.py @@ -0,0 +1,407 @@ +#=========================================================================== +# +# DimmerBase Class. Specifically Ramp_Rate and On_Level Flags, +# increment_up and increment_down functions. Extensions to ManualCtrl +# Plus other dimmer helper functions +# +# NOTE! This is a meta class that include Responder and ManualCtrl. DO NOT +# inherit from these classes if you are using this Meta class. +# +#=========================================================================== +import functools +from .ResponderBase import ResponderBase +from .Base import Base +from ..functions import ManualCtrl +from ... import handler +from ... import log +from ... import message as Msg +from ... import util +from ... import on_off + + +LOG = log.get_logger() + + +class DimmerBase(ManualCtrl, ResponderBase, Base): + """Dimmer Functions Trait Abstract Class + + This is an abstract class that provides support for the ramp_rate and + on_level flags found on dimmer devices. + """ + # Mapping of ramp rates to human readable values + ramp_pretty = {0x00: 540, 0x01: 480, 0x02: 420, 0x03: 360, 0x04: 300, + 0x05: 270, 0x06: 240, 0x07: 210, 0x08: 180, 0x09: 150, + 0x0a: 120, 0x0b: 90, 0x0c: 60, 0x0d: 47, 0x0e: 43, 0x0f: 39, + 0x10: 34, 0x11: 32, 0x12: 30, 0x13: 28, 0x14: 26, + 0x15: 23.5, 0x16: 21.5, 0x17: 19, 0x18: 8.5, 0x19: 6.5, + 0x1a: 4.5, 0x1b: 2, 0x1c: .5, 0x1d: .3, 0x1e: .2, 0x1f: .1} + + def __init__(self, protocol, modem, address, name=None): + """Constructor + + Args: + protocol (Protocol): The Protocol object used to communicate + with the Insteon network. This is needed to allow the + device to send messages to the PLM modem. + modem (Modem): The Insteon modem used to find other devices. + address (Address): The address of the device. + name (str): Nice alias name to use for the device. + """ + super().__init__(protocol, modem, address, name) + + # Remote (mqtt) commands mapped to methods calls. Add to the base + # class defined commands. + self.cmd_map.update({ + 'increment_up' : self.increment_up, + 'increment_down' : self.increment_down, + }) + + # Define the flags handled by set_flags() + self.set_flags_map.update({'on_level': self.set_on_level, + 'ramp_rate': self.set_ramp_rate}) + + #========= Flags Functions + #----------------------------------------------------------------------- + def set_on_level(self, on_done=None, **kwargs): + """Set the device default on level. + + This changes the dimmer level the device will go to when the on + button is pressed. This can be very useful because a double-tap + (fast-on) will the turn the device to full brightness if needed. + + Args: + level (int): The default on level in the range [0,255] + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + # Check for valid input + level = util.input_byte(kwargs, 'on_level') + if level is None: + LOG.error("Invalid on level.") + on_done(False, 'Invalid on level.', None) + return + + LOG.info("Device %s setting on level to %s", self.label, level) + + # Extended message data - see Insteon dev guide p156. + data = bytes([ + 0x01, # D1 must be group 0x01 + 0x06, # D2 set on level when button is pressed + level, # D3 brightness level + ] + [0x00] * 11) + + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) + + # Use the standard command handler which will notify us when the + # command is ACK'ed. + callback = functools.partial(self.handle_on_level, level=level) + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def set_ramp_rate(self, on_done=None, **kwargs): + """Set the device default ramp rate. + + This changes the dimmer default ramp rate of how quickly it will + turn on or off. This rate can be between 0.1 seconds and up to 9 + minutes. + + Args: + rate (float): Ramp rate in in the range [0.1, 540] seconds + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + # Check for valid input + rate = util.input_float(kwargs, 'ramp_rate') + if rate is None: + LOG.error("Invalid ramp rate.") + on_done(False, 'Invalid ramp rate.', None) + return + + LOG.info("Device %s setting ramp rate to %s", self.label, rate) + + data_3 = 0x1c # the default ramp rate is .5 + for ramp_key, ramp_value in self.ramp_pretty.items(): + if rate >= ramp_value: + data_3 = ramp_key + break + + # Extended message data - see Insteon dev guide p156. + data = bytes([ + 0x01, # D1 must be group 0x01 + 0x05, # D2 set ramp rate when button is pressed + data_3, # D3 rate + ] + [0x00] * 11) + + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) + + # Use the standard command handler which will notify us when the + # command is ACK'ed. + callback = self.generic_ack_callback("Button ramp rate updated") + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def handle_on_level(self, msg, on_done, level): + """Callback for handling set_on_level() responses. + + This is called when we get a response to the set_on_level() command. + Update stored on-level in device DB and call the on_done callback with + the status. + + Args: + msg (InpStandard): The response message from the command. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + self.db.set_meta('on_level', level) + on_done(True, "Button on level updated", None) + + #----------------------------------------------------------------------- + def get_on_level(self): + """Look up previously-set on-level in device database, if present + + This is called when we need to look up what is the default on-level + (such as when getting an ON broadcast message from the device). + + If on_level is not found in the DB, assumes on-level is full-on. + """ + on_level = self.db.get_meta('on_level') + if on_level is None: + on_level = 0xff + return on_level + + #========= Increment Functions + #----------------------------------------------------------------------- + def increment_up(self, reason="", on_done=None): + """Increment the current level up. + + Levels increment in units of 8 (32 divisions from off to on). + + 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 + the state changed signals. + + Args: + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("Device %s cmd: increment up", self.addr) + + msg = Msg.OutStandard.direct(self.addr, 0x15, 0x00) + + callback = functools.partial(self.handle_increment, delta=+8, + reason=reason) + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def increment_down(self, reason="", on_done=None): + """Increment the current level down. + + Levels increment in units of 8 (32 divisions from off to on). + + 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 + the state changed signals. + + Args: + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("Device %s cmd: increment down", self.addr) + + msg = Msg.OutStandard.direct(self.addr, 0x16, 0x00) + + callback = functools.partial(self.handle_increment, delta=-8, + reason=reason) + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def handle_increment(self, msg, on_done, delta, reason="", group=0x01): + """Callback for increment up/down commanded messages. + + This callback is run when we get a reply back from triggering an + increment up or down on the device. If the command was ACK'ed, we + know it worked. + + 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) + + delta (int): The amount +/- of level to change by. + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + """ + # If this it the ACK we're expecting, update the internal state and + # emit our signals. + LOG.debug("Device %s ACK: %s", self.addr, msg) + + # Add the delta and bound at [0, 255] + level = min(self._level + delta, 255) + level = max(level, 0) + self._set_state(group=group, level=level, reason=reason) + + s = "Device %s state updated to %s" % (self.addr, self._level) + on_done(True, s, msg.cmd2) + + #========= Helper Functions + #----------------------------------------------------------------------- + def derive_on_level(self, mode): + """Calculates the device on level based on the mode and the local + on_level set in the flags. + + When a device is turned on using the physical button it will go to the + on_level defined in its flags, unless it was a FAST on or the device + was already on and was activated again in those cases it always goes to + level 0xFF. + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + Returns: + level (int) + """ + if mode == on_off.Mode.FAST: + # Fast-ON command. Use full-brightness. + level = 0xff + else: + # Normal/instant ON command. Use default on-level. + # Check if we saved the default on-level in the device + # database when setting it. + level = self.get_on_level() + if self._level == level: + # Pressing on again when already at the default on + # level causes the device to go to full-brightness. + level = 0xff + return level + + #----------------------------------------------------------------------- + def derive_off_level(self, mode): + """Calculates the device off level based on the mode and the local + on_level set in the flags. + + This always returns 0x00 for dimmer devices. By setting the level to + not None, this will cause the mqtt template_data to produce variables + based on the level data. This may be a bit silly, but keeps things + compatible with how it previously worked. + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + Returns: + level (int) + """ + level = 0x00 + return level + + #----------------------------------------------------------------------- + def group_cmd_on_level(self, entry, is_on): + """Get the On Level for this Group Command + + For switches, this always returns None as this forces template_data + in the MQTT classes to render without level data to comply with prior + versions. But dimmers allow for the local on_level to be user defined + and stored in the db entry. + + Args: + entry (DeviceEntry): The local db entry for this group command. + is_on (bool): Whether the command was ON or OFF + Returns: + level (int): The on_level or None + """ + level = entry.data[0] if is_on else 0x00 + return level + + #----------------------------------------------------------------------- + def group_cmd_handle_increment(self, cmd, group, reason): + """Process Increment Group Commands + + This should do whatever processing is necessary, including updating + the local state in response to an increment group command. For non + dimmable devices this does nothing. + + Args: + cmd (Msg.CmdType): The cmd1 value of the message + group (int): The local db entry for this group command. + reason (str): Whether the command was ON or OFF + """ + # Increment up 1 unit which is 8 levels. + if cmd == Msg.CmdType.BRIGHT: + self._set_state(group=group, level=min(0xff, self._level + 8), + reason=reason) + + # Increment down 1 unit which is 8 levels. + elif cmd == Msg.CmdType.DIM: + self._set_state(group=group, level=max(0x00, self._level - 8), + reason=reason) + + #----------------------------------------------------------------------- + def group_cmd_handle_manual(self, manual, group, reason): + """Process Manual Group Commands + + This should do whatever processing is necessary, including updating + the local state in response to a manual group command. For non + dimmable devices this does nothing. + + Args: + manual (on_off.Manual): The manual mode + group (int): The local db entry for this group command. + reason (str): Whether the command was ON or OFF + """ + self.signal_manual.emit(self, button=group, manual=manual, + reason=reason) + + # If the button is released, refresh to get the final level. + if manual == on_off.Manual.STOP: + self.refresh() + + #----------------------------------------------------------------------- + def handle_refresh(self, msg, group=None): + """Callback for handling refresh() responses. + + This is called when we get a response to the refresh() command. The + refresh command reply will contain the current device state in cmd2 + and this updates the device with that value. It is called by + handler.DeviceRefresh when we can an ACK for the refresh command. + + Overrides Base.handle_refresh in order to add the level key to + _set_state(). + + Args: + msg (message.InpStandard): The refresh message reply. The current + device state is in the msg.cmd2 field. + """ + LOG.ui("Device %s refresh cmd2 %s", self.addr, msg.cmd2) + + # Level works for most things can add a derive state if needed. + self._set_state(level=msg.cmd2, group=group, + reason=on_off.REASON_REFRESH) + + #========= Manual Functions + #----------------------------------------------------------------------- + def react_to_manual(self, manual, group, reason): + """React to Manual Mode Received from the Device + + Non-dimmable devices react immediatly when issueing a manual command + while dimmable devices slowly ramp on. This function is here to + provide DimmerBase a place to alter the default functionality. This + function should call _set_state() at the appropriate times to update + the state of the device. + + Args: + manual (on_off.Manual): The manual command type + group (int): The group sending the command + reason (str): The reason string to pass on + """ + # Refresh to get the new level after the button is released. + # do nothing on UP and DOWN + if manual == on_off.Manual.STOP: + self.refresh() diff --git a/insteon_mqtt/device/base/ResponderBase.py b/insteon_mqtt/device/base/ResponderBase.py new file mode 100644 index 00000000..6f7044e2 --- /dev/null +++ b/insteon_mqtt/device/base/ResponderBase.py @@ -0,0 +1,374 @@ +#=========================================================================== +# +# Provides the Base Functions for Devices that are Responders +# +#=========================================================================== +import functools +from .Base import Base +from ... import message as Msg +from ... import handler +from ... import log +from ... import on_off + + +LOG = log.get_logger() + + +class ResponderBase(Base): + """Responder Functions Abstract Classes + + This is an abstract class that provides support for the the functions used + by responder devices. Responders are devices that can be controlled by + the modem or some other device. BatterySensors are generally not + responders since they are not awake to hear messages, but generally + everything else is. + + This class is meant to be extended by other classes including DimmerBase + so it should generally be inheritted last. + """ + def __init__(self, protocol, modem, address, name=None): + """Constructor + + Args: + protocol (Protocol): The Protocol object used to communicate + with the Insteon network. This is needed to allow the + device to send messages to the PLM modem. + modem (Modem): The Insteon modem used to find other devices. + address (Address): The address of the device. + name (str): Nice alias name to use for the device. + """ + super().__init__(protocol, modem, address, name) + + self.cmd_map.update({ + 'on' : self.on, + 'off' : self.off, + 'set' : self.set, + }) + + # List of responder group numbers + self.responder_groups = [0x01] + + #----------------------------------------------------------------------- + def set(self, is_on=None, level=None, group=0x01, mode=on_off.Mode.NORMAL, + reason="", transition=None, on_done=None): + """Turn the device on or off. Level zero will be 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. + + 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 + the state changed signals. + + Args: + is_on (bool): True to turn on, False for off + level (int): If non zero, turn the device on. Should be in the + range 0 to 255. If None, use default on-level. + group (int): The group to send the command to. For this device, + this must be 1. Allowing a group here gives us a consistent + API to the on command across devices. + mode (on_off.Mode): The type of command to send (normal, fast, etc). + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + transition (int): The transition ramp_rate if supported. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + if is_on or level: + self.on(group=group, level=level, mode=mode, reason=reason, + transition=transition, on_done=on_done) + else: + self.off(group=group, mode=mode, reason=reason, + transition=transition, on_done=on_done) + + #----------------------------------------------------------------------- + def on(self, group=0x01, level=None, mode=on_off.Mode.NORMAL, reason="", + transition=None, on_done=None): + """Turn the device on. + + NOTE: This does NOT simulate a button press on the device - it just + changes the state of the device. It will not trigger any responders + that are linked to this device. To simulate a button press, call the + scene() method. + + This 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 + the state changed signals. + + Args: + group (int): The group to send the command to. + level (int): If non-zero, turn the device on. The API is an int + to keep a consistent API with other devices. + mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Transition time in seconds if supported. + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("Device %s grp: %s cmd: on %s", self.addr, group, mode) + assert group in self.responder_groups + assert isinstance(mode, on_off.Mode) + + # Send the requested on code value. + cmd1, cmd2 = self.cmd_on_values(mode, level, transition, group) + + # Use the standard command handler which will notify us when the + # command is ACK'ed. + msg = Msg.OutStandard.direct(self.addr, cmd1, cmd2) + callback = functools.partial(self.handle_ack, reason=reason) + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def off(self, group=0x01, mode=on_off.Mode.NORMAL, reason="", + transition=None, on_done=None): + """Turn the device 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. + + 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 + the state changed signals. + + Args: + group (int): The group to send the command to. + mode (on_off.Mode): The type of command to send (normal, fast, etc). + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + LOG.info("Device %s grp: %s cmd: off %s", self.addr, group, mode) + assert group in self.responder_groups + assert isinstance(mode, on_off.Mode) + + # Send an off or instant off command. + cmd1, cmd2 = self.cmd_off_values(mode, transition, group) + + # Use the standard command handler which will notify us when the + # command is ACK'ed. + msg = Msg.OutStandard.direct(self.addr, cmd1, cmd2) + callback = functools.partial(self.handle_ack, reason=reason) + msg_handler = handler.StandardCmd(msg, callback, on_done) + self.send(msg, msg_handler) + + #----------------------------------------------------------------------- + def cmd_on_values(self, mode, level, transition, group): + """Calculate Cmd Values for On + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + level (int): On level between 0-255. + transition (int): Ramp rate for the transition in seconds. + group (int): The group number that this state applies to. Defaults + to None. + Returns + cmd1, cmd2 (int): Value of cmds for this device. + """ + if transition or mode == on_off.Mode.RAMP: + LOG.error("Device %s does not support transition.", self.addr) + mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode + if level: + LOG.error("Device %s does not support level.", self.addr) + cmd1 = on_off.Mode.encode(True, mode) + cmd2 = 0xFF + return (cmd1, cmd2) + + #----------------------------------------------------------------------- + def cmd_off_values(self, mode, transition, group): + """Calculate Cmd Values for Off + + Args: + mode (on_off.Mode): The type of command to send (normal, fast, etc). + transition (int): Ramp rate for the transition in seconds. + group (int): The group number that this state applies to. Defaults + to None. + Returns + cmd1, cmd2 (int): Value of cmds for this device. + """ + if transition or mode == on_off.Mode.RAMP: + LOG.error("Device %s does not support transition.", self.addr) + mode = on_off.Mode.NORMAL if mode == on_off.Mode.RAMP else mode + cmd1 = on_off.Mode.encode(False, mode) + cmd2 = 0x00 + return (cmd1, cmd2) + + #----------------------------------------------------------------------- + def handle_ack(self, msg, on_done, reason=""): + """Callback for standard commanded messages. + + This callback is run when we get a reply back from one of our + 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. + + 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) + reason (str): This is optional and is used to identify why the + command was sent. It is passed through to the output signal + when the state changes - nothing else is done with it. + """ + # If this it the ACK we're expecting, update the internal state and + # emit our signals. + LOG.debug("Device %s ACK: %s", self.addr, msg) + + is_on, level, mode, group = self.decode_on_level(msg.cmd1, msg.cmd2) + if is_on is None: + on_done(False, "Unable to decode Device %s state. %s" % self.addr, + msg) + else: + reason = reason if reason else on_off.REASON_COMMAND + self._set_state(is_on=is_on, level=level, mode=mode, group=group, + reason=reason) + on_done(True, "Device state updated to on=%s" % is_on, is_on) + + #----------------------------------------------------------------------- + def decode_on_level(self, cmd1, cmd2): + """Callback for standard commanded messages. + + Decodes the cmds recevied from the device into is_on, level, and mode + to be consumed by _set_state(). + + Args: + cmd1 (byte): The command 1 value + cmd2 (byte): The command 2 value + Returns: + is_on (bool): Is the device on. + mode (on_off.Mode): The type of command to send (normal, fast, etc). + level (int): On level between 0-255. + group (int): The group number that this state applies to. Defaults + to None. + """ + is_on, mode = on_off.Mode.decode(cmd1) + level = on_off.Mode.decode_level(cmd1, cmd2) + return (is_on, level, mode, None) + + #----------------------------------------------------------------------- + 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 + device that received the broadcast message (handle_broadcast) will + call this method for every device that is linked to it. The device + should look up the responder entry for the group in it's all link + database and update it's state accordingly. + + Args: + addr (Address): The device that sent the message. This is the + controller in the scene. + msg (InpStandard): 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, msg.group, is_controller=False) + if not entry: + LOG.error("Device %s has no group %s entry from %s", self.label, + msg.group, addr) + return + + reason = on_off.REASON_SCENE + localGroup = self.group_cmd_local_group(entry) + + # Handle on/off codes + if on_off.Mode.is_valid(msg.cmd1): + LOG.info("Device %s processing on/off group %s cmd from %s", + self.label, msg.group, addr) + is_on, mode = on_off.Mode.decode(msg.cmd1) + level = self.group_cmd_on_level(entry, is_on) + self._set_state(group=localGroup, is_on=is_on, level=level, + mode=mode, reason=reason) + + elif msg.cmd1 in (0x15, 0x16): + LOG.info("Device %s processing increment group %s cmd from %s", + self.label, msg.group, addr) + self.group_cmd_handle_increment(msg.cmd1, localGroup, reason) + + # Starting/stopping manual increment (cmd2 0x00=up, 0x01=down) + elif on_off.Manual.is_valid(msg.cmd1): + LOG.info("Device %s processing manual group %s cmd from %s", + self.label, msg.group, addr) + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + self.group_cmd_handle_manual(manual, localGroup, reason) + + else: + LOG.warning("Device %s unknown cmd %#04x", self.addr, + msg.cmd1) + + #----------------------------------------------------------------------- + def group_cmd_local_group(self, entry): + """Get the Local Group Affected by this Group Command + + For most devices this is group 1, but for multigroup devices such + as the KPL, they may need to decode the local group from the + entry data. + + Args: + entry (DeviceEntry): The local db entry for this group command. + Returns: + group (int): The local group affected + """ + return 0x01 + + #----------------------------------------------------------------------- + def group_cmd_on_level(self, entry, is_on): + """Get the On Level for this Group Command + + For switches, this always returns None as this forces template_data + in the MQTT classes to render without level data to comply with prior + versions. But dimmers allow for the local on_level to be user defined + and stored in the db entry. + + Args: + entry (DeviceEntry): The local db entry for this group command. + is_on (bool): Whether the command was ON or OFF + Returns: + level (int): The on_level or None + """ + level = None + return level + + #----------------------------------------------------------------------- + def group_cmd_handle_increment(self, cmd, group, reason): + """Process Increment Group Commands + + This should do whatever processing is necessary, including updating + the local state in response to an increment group command. For non + dimmable devices this does nothing. + + Args: + cmd (Msg.CmdType): The cmd1 value of the message + group (int): The local db entry for this group command. + reason (str): Whether the command was ON or OFF + """ + # I am not sure I am aware of increment group commands. Is there + # some way I can cause one to occur? + pass + + #----------------------------------------------------------------------- + def group_cmd_handle_manual(self, manual, group, reason): + """Process Manual Group Commands + + This should do whatever processing is necessary, including updating + the local state in response to a manual group command. For non + dimmable devices this does nothing, as they do not react to manual + commands. + + Args: + manual (on_off.Manual): The manual mode + group (int): The local db entry for this group command. + reason (str): Whether the command was ON or OFF + """ + pass diff --git a/insteon_mqtt/device/base/__init__.py b/insteon_mqtt/device/base/__init__.py new file mode 100644 index 00000000..190e9d52 --- /dev/null +++ b/insteon_mqtt/device/base/__init__.py @@ -0,0 +1,31 @@ +#=========================================================================== +# +# Insteon device Base classes +# +#=========================================================================== +# flake8: noqa + +__doc__ = """Insteon device Base classes. + +These are all abstract classes that provide the base support for device +classes to further extend. ONLY ONE BASE CLASS SHOULD BE INHERITTED by +a device class. + +Base - Provides very basic support for devices that emit controller signals +but do not have any responder objects. This include things like BatterySensors +and SmokeBridge. + +ResponderBase - Extends Base to provide basic functionality for devices that +have simple responder objects. This includes things like Switches and +Outlets. + +DimmerBase - Extends ResponderBase and ManualCtrl to provide dimmer +functionality for devices that have responders that can be dimmed, such as +Dimmers, FanLinc, KeypadLincDimmer. +""" + +#=========================================================================== + +from .Base import Base +from .ResponderBase import ResponderBase +from .DimmerBase import DimmerBase diff --git a/insteon_mqtt/device/functions/Backlight.py b/insteon_mqtt/device/functions/Backlight.py new file mode 100644 index 00000000..f3e743cf --- /dev/null +++ b/insteon_mqtt/device/functions/Backlight.py @@ -0,0 +1,92 @@ +#=========================================================================== +# +# Backlight Functions. +# +#=========================================================================== +from ..base import Base +from ... import handler +from ... import log +from ... import message as Msg +from ... import util +from ...CommandSeq import CommandSeq + + +LOG = log.get_logger() + + +class Backlight(Base): + """Backlight Trait Abstract Class + + This is an abstract class that provides support for controlling the + backlight on devices. + """ + def __init__(self, protocol, modem, address, name=None): + """Constructor + + Args: + protocol (Protocol): The Protocol object used to communicate + with the Insteon network. This is needed to allow the + device to send messages to the PLM modem. + modem (Modem): The Insteon modem used to find other devices. + address (Address): The address of the device. + name (str): Nice alias name to use for the device. + """ + super().__init__(protocol, modem, address, name) + + # Define the flags handled by set_flags() + self.set_flags_map.update({'backlight': self.set_backlight}) + + #----------------------------------------------------------------------- + def set_backlight(self, on_done=None, **kwargs): + """Set the device backlight level. + + This changes the level of the LED back light that is used by the + device status LED's (dimmer levels, KeypadLinc buttons, etc). + + The default factory level is 0x1f. + + Per page 157 of insteon dev guide range is between 0x11 and 0x7F, + however in practice backlight can be incremented from 0x00 to at least + 0x7f. + + Args: + level (int): The backlight level in the range [0,255] + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + # Check for valid input + level = util.input_byte(kwargs, 'backlight') + if level is None: + LOG.error("Invalid backlight level.") + on_done(False, 'Invalid backlight level.', None) + return + + seq = CommandSeq(self, "Device set backlight complete", on_done, + name="SetBacklight") + + # First set the backlight on or off depending on level value + is_on = level > 0 + LOG.info("Device %s setting backlight to %s", self.label, is_on) + cmd = 0x09 if is_on else 0x08 + msg = Msg.OutExtended.direct(self.addr, 0x20, cmd, bytes([0x00] * 14)) + callback = self.generic_ack_callback("Backlight set on: %s" % is_on) + msg_handler = handler.StandardCmd(msg, callback, on_done) + seq.add_msg(msg, msg_handler) + + if is_on: + # Second set the level only if on + LOG.info("Device %s setting backlight to %s", self.label, level) + + # Extended message data - see Insteon dev guide p156. + data = bytes([ + 0x01, # D1 must be group 0x01 + 0x07, # D2 set global led brightness + level, # D3 brightness level + ] + [0x00] * 11) + + msg = Msg.OutExtended.direct(self.addr, 0x2e, 0x00, data) + callback = self.generic_ack_callback("Backlight level updated") + msg_handler = handler.StandardCmd(msg, callback, on_done) + seq.add_msg(msg, msg_handler) + + seq.run() diff --git a/insteon_mqtt/device/functions/ManualCtrl.py b/insteon_mqtt/device/functions/ManualCtrl.py new file mode 100644 index 00000000..8fb92ce4 --- /dev/null +++ b/insteon_mqtt/device/functions/ManualCtrl.py @@ -0,0 +1,99 @@ +#=========================================================================== +# +# ManualCtrl Functions. +# +#=========================================================================== +from ..base import Base +from ... import log +from ... import on_off +from ...Signal import Signal + + +LOG = log.get_logger() + + +class ManualCtrl(Base): + """Manual Control Trait Abstract Class + + This is an abstract class that provides support for devices which can + broadcast manual commands. Interestingly, non-dimmable devices can + emit manual commands, but they do not respond to them. As a result, the + breakdown of manual feature is a little weird in our class structure. + + Again, this class provides support for devices that emit manual control + messages, but it does not provide support for responding to manual. + The abstract support for responding to manual commands is in Responder + and the main functions for devices that support responding to manual + commands is in DimmerBase. + + DimmerBase inherits from this class. If an object inherits from + DimmerBase, it SHOULD NOT ALSO INHERIT FROM THIS CLASS. + + The MQTT topic for manual messages is disabled by default. But a device + that has been affected by a manual command will be refresh()'d when the + Manual.STOP command is sent, causing the state of that device to be + updated. + + - signal_manual( Device, on_off.Manual mode, str reason ): Sent when the + device starts or stops manual mode (when a button is held down or + released). + """ + def __init__(self, protocol, modem, address, name=None): + """Constructor + + Args: + protocol (Protocol): The Protocol object used to communicate + with the Insteon network. This is needed to allow the + device to send messages to the PLM modem. + modem (Modem): The Insteon modem used to find other devices. + address (Address): The address of the device. + name (str): Nice alias name to use for the device. + """ + super().__init__(protocol, modem, address, name) + + # Manual mode start up, down, off + # API: func(Device, on_off.Manual mode, str reason) + self.signal_manual = Signal() + + #----------------------------------------------------------------------- + def process_manual(self, msg, reason): + """Handle Manual Mode Received from the Device + + This is called as part of the handle_broadcast response. It + processes the manual mode changes sent by the device. + + Args: + msg (InpStandard): Broadcast message from the device. Use + msg.group to find the group and msg.cmd1 for the command. + reason (str): The reason string to pass on + """ + manual = on_off.Manual.decode(msg.cmd1, msg.cmd2) + LOG.info("Device %s grp: %s manual change %s", self.addr, msg.group, + manual) + self.signal_manual.emit(self, button=msg.group, manual=manual, + reason=reason) + self.react_to_manual(manual, msg.group, reason) + + #----------------------------------------------------------------------- + def react_to_manual(self, manual, group, reason): + """React to Manual Mode Received from the Device + + Non-dimmable devices react immediatly when issueing a manual command + while dimmable devices slowly ramp on. This function is here to + provide DimmerBase a place to alter the default functionality. This + function should call _set_state() at the appropriate times to update + the state of the device. + + Args: + manual (on_off.Manual): The manual command type + group (int): The group sending the command + reason (str): The reason string to pass on + """ + # Switches change state when the switch is held (not all devices + # do this). + if manual == on_off.Manual.UP: + self._set_state(is_on=True, group=group, mode=on_off.Mode.MANUAL, + reason=reason) + elif manual == on_off.Manual.DOWN: + self._set_state(is_on=False, group=group, mode=on_off.Mode.MANUAL, + reason=reason) diff --git a/insteon_mqtt/device/functions/Scene.py b/insteon_mqtt/device/functions/Scene.py index 265b211d..7b1c7a7d 100644 --- a/insteon_mqtt/device/functions/Scene.py +++ b/insteon_mqtt/device/functions/Scene.py @@ -5,7 +5,7 @@ # #=========================================================================== import time -from ..Base import Base +from ..base import Base from ... import handler from ... import log from ... import message as Msg @@ -37,10 +37,6 @@ def __init__(self, protocol, modem, address, name=None): 'scene' : self.scene, }) - # Special callback to run when receiving a broadcast clean up. See - # scene() for details. - self.broadcast_reason = "" - # NOTE! # The class extending this class needs to define the controller groups # in the self.group_map. Only these groups will be valid scene @@ -125,6 +121,6 @@ def our_on_done(success, msg, data): else: self.broadcast_reason = on_off.REASON_DEVICE on_done(success, msg, data) - msg_handler = handler.StandardCmd(msg, self.handle_generic_ack, - our_on_done) + callback = self.generic_ack_callback("Device acknowledged scene cmd.") + msg_handler = handler.StandardCmd(msg, callback, our_on_done) self.send(msg, msg_handler) diff --git a/insteon_mqtt/device/functions/Set.py b/insteon_mqtt/device/functions/Set.py deleted file mode 100644 index cfddaa1d..00000000 --- a/insteon_mqtt/device/functions/Set.py +++ /dev/null @@ -1,81 +0,0 @@ -#=========================================================================== -# -# Set Functions. -# -#=========================================================================== -from ..Base import Base -from ... import log -from ... import on_off - -LOG = log.get_logger() - - -class Set(Base): - """Scene Trait Abstract Class - - This is an abstract class that provides support for the Scene topic. - """ - def __init__(self, protocol, modem, address, name=None): - """Constructor - - Args: - protocol (Protocol): The Protocol object used to communicate - with the Insteon network. This is needed to allow the - device to send messages to the PLM modem. - modem (Modem): The Insteon modem used to find other devices. - address (Address): The address of the device. - name (str): Nice alias name to use for the device. - """ - super().__init__(protocol, modem, address, name) - - self.cmd_map.update({ - 'set' : self.set, - }) - - #----------------------------------------------------------------------- - def set(self, is_on=None, level=None, group=0x01, mode=on_off.Mode.NORMAL, - reason="", transition=None, on_done=None): - """Turn the device on or off. Level zero will be 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. - - 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 - the state changed signals. - - Args: - is_on (bool): True to turn on, False for off - level (int): If non zero, turn the device on. Should be in the - range 0 to 255. If None, use default on-level. - group (int): The group to send the command to. For this device, - this must be 1. Allowing a group here gives us a consistent - API to the on command across devices. - mode (on_off.Mode): The type of command to send (normal, fast, etc). - reason (str): This is optional and is used to identify why the - command was sent. It is passed through to the output signal - when the state changes - nothing else is done with it. - transition (int): The transition ramp_rate if supported. - on_done: Finished callback. This is called when the command has - completed. Signature is: on_done(success, msg, data) - """ - if is_on or level: - self.on(group=group, level=level, mode=mode, reason=reason, - transition=transition, on_done=on_done) - else: - self.off(group=group, mode=mode, reason=reason, - transition=transition, on_done=on_done) - - #----------------------------------------------------------------------- - def on(self, group, level, mode, reason, transition, on_done): - # TODO Might be able to move these functions in here too - # Probably put switch functions here and dimmer in a seperate - # level file. Need to move handle_ack and all subsequent functions - # too. KPL, IO devices would remain their own thing - raise NotImplementedError() - - #----------------------------------------------------------------------- - def off(self, group, mode, reason, transition, on_done): - raise NotImplementedError() diff --git a/insteon_mqtt/device/functions/__init__.py b/insteon_mqtt/device/functions/__init__.py index 9aa62ec8..b744b767 100644 --- a/insteon_mqtt/device/functions/__init__.py +++ b/insteon_mqtt/device/functions/__init__.py @@ -17,6 +17,6 @@ #=========================================================================== -from ..Base import Base from .Scene import Scene -from .Set import Set +from .Backlight import Backlight +from .ManualCtrl import ManualCtrl diff --git a/insteon_mqtt/handler/Broadcast.py b/insteon_mqtt/handler/Broadcast.py index f85e78e6..f85c482d 100644 --- a/insteon_mqtt/handler/Broadcast.py +++ b/insteon_mqtt/handler/Broadcast.py @@ -28,7 +28,10 @@ class Broadcast(Base): Finally a broadcast LINK_CLEANUP_REPORT is sent. This message indicates if the device received ACKs from all linked devices or not. This message indicates that the device is finished sending messages. However, as - broadcast message, it is not guaranteed to be received. + broadcast message, it is not guaranteed to be received. Some devices + even have a user option to turn off these messages. These messages + contain information about the success or failure of the broadcast but + do not contain the ON/OFF value of the broadcast. This handler will call device.handle_broadcast(msg) for the device that sends the message. @@ -92,8 +95,6 @@ def msg_received(self, protocol, msg): if msg.flags.type == Msg.Flags.Type.ALL_LINK_BROADCAST: if msg.cmd1 == Msg.CmdType.LINK_CLEANUP_REPORT: # This is the final broadcast signalling completion. - # All of these messages will be forwarded to the device - # potentially even duplicates # Re-enable sending # First clear wait time protocol.set_wait_time(0) @@ -106,7 +107,7 @@ def msg_received(self, protocol, msg): else: text = "Cleanup report for %s, grp %s had %d fails." LOG.warning(text, msg.from_addr, msg.group, msg.cmd2) - return self._process(msg, protocol, wait_time) + return Msg.CONTINUE else: # This is the initial broadcast or an echo of it. if self._should_process(msg, wait_time): diff --git a/insteon_mqtt/handler/BroadcastCmdResponse.py b/insteon_mqtt/handler/BroadcastCmdResponse.py index ca3d5663..6bd82ae0 100644 --- a/insteon_mqtt/handler/BroadcastCmdResponse.py +++ b/insteon_mqtt/handler/BroadcastCmdResponse.py @@ -16,8 +16,9 @@ class BroadcastCmdResponse(Base): This class handles responses from the device where the device sends an ACK but a subsequent broadcast message is sent with the requested payload. - The handler watches for the proper standard length ACK, returns - a continue and then waits for the broadcast payload. + The handler watches for the proper PLM ACK, followed by a standard length + ACK from the device, and then only after these two prior ACKs have been + received, will it call the callback with a broadcast message received. """ def __init__(self, msg, callback, on_done=None, num_retry=3): """Constructor @@ -37,6 +38,7 @@ def __init__(self, msg, callback, on_done=None, num_retry=3): self.addr = msg.to_addr self.cmd = msg.cmd1 self.callback = callback + self._device_ACK = False #----------------------------------------------------------------------- def msg_received(self, protocol, msg): @@ -83,6 +85,7 @@ def msg_received(self, protocol, msg): if msg.flags.type == Msg.Flags.Type.DIRECT_ACK: LOG.info("%s device ACK response, waiting for broadcast " "payload", msg.from_addr) + self._device_ACK = True return Msg.CONTINUE elif msg.flags.type == Msg.Flags.Type.DIRECT_NAK: @@ -102,9 +105,11 @@ def msg_received(self, protocol, msg): LOG.warning("%s device unexpected msg: %s", msg.from_addr, msg) return Msg.UNKNOWN - # Process the payload reply. + # Process the broadcast payload reply only if PLM and device ACKs + # received previously elif (isinstance(msg, Msg.InpStandard) and - msg.flags.type == Msg.Flags.Type.BROADCAST and self._PLM_ACK): + msg.flags.type == Msg.Flags.Type.BROADCAST and self._PLM_ACK and + self._device_ACK): # Filter by address and command. if msg.from_addr == self.addr: # Run the callback - it's up to the callback to check if this diff --git a/insteon_mqtt/handler/ModemGetFlags.py b/insteon_mqtt/handler/ModemGetFlags.py new file mode 100644 index 00000000..e9c6da49 --- /dev/null +++ b/insteon_mqtt/handler/ModemGetFlags.py @@ -0,0 +1,63 @@ +#=========================================================================== +# +# Modem get_flags handler. +# +#=========================================================================== +from .. import log +from .. import message as Msg +from .Base import Base + +LOG = log.get_logger() + + +class ModemGetFlags(Base): + """Modem get_flags handler. + + This handles a `get flags` command being sent to the modem. The response + to this command is a single message containing the flags and 2 spare + bytes. + """ + def __init__(self, modem, on_done=None): + """Constructor + + Args + modem (Modem): The Insteon modem object. + on_done: The finished callback. Calling signature: + on_done( bool success, str message, data ) + """ + super().__init__(on_done) + + self.modem = modem + + #----------------------------------------------------------------------- + def msg_received(self, protocol, msg): + """See if we can handle the message. + + If we get an ACK of the user reset, we'll clear the modem database. + + Args: + protocol (Protocol): The Insteon Protocol object + msg: Insteon message object that was read. + + Returns: + Msg.UNKNOWN if we can't handle this message. + Msg.CONTINUE if we handled the message and expect more. + Msg.FINISHED if we handled the message and are done. + """ + if not self._PLM_sent: + # If PLM hasn't sent our message yet, this can't be for us + return Msg.UNKNOWN + if isinstance(msg, Msg.OutGetModemFlags): + if msg.is_ack: + LOG.ui("Modem flag byte is: %s, spare bytes are: %s, %s", + msg.modem_flags, msg.spare1, msg.spare2) + self.on_done(True, "Modem get_flags complete", None) + else: + LOG.error("Modem get_flags failed") + self.on_done(False, "Modem get_flags failed", None) + + return Msg.FINISHED + + return Msg.UNKNOWN + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/handler/__init__.py b/insteon_mqtt/handler/__init__.py index fb281e5f..25d0cdea 100644 --- a/insteon_mqtt/handler/__init__.py +++ b/insteon_mqtt/handler/__init__.py @@ -44,6 +44,7 @@ from .ModemLinkComplete import ModemLinkComplete from .ModemLinkStart import ModemLinkStart from .ModemReset import ModemReset +from .ModemGetFlags import ModemGetFlags from .ModemScene import ModemScene from .StandardCmd import StandardCmd from .StandardCmdNAK import StandardCmdNAK diff --git a/insteon_mqtt/message/OutGetModemFlags.py b/insteon_mqtt/message/OutGetModemFlags.py new file mode 100644 index 00000000..0ef21634 --- /dev/null +++ b/insteon_mqtt/message/OutGetModemFlags.py @@ -0,0 +1,87 @@ +#=========================================================================== +# +# Output insteon reset the PLM modem message. +# +#=========================================================================== +from .Base import Base + + +class OutGetModemFlags(Base): + """Command requesting the modem configuration + + This command will retun 6 bytes: + - 0x02 + - 0x73 + - Modem Configuration Flags + - Spare 1 + - Spare 2 + - Ack/Nak + """ + msg_code = 0x73 + fixed_msg_size = 6 + + #----------------------------------------------------------------------- + @classmethod + def from_bytes(cls, raw): + """Read the message from a byte stream. + + This should only be called if raw[1] == msg_code and len(raw) >= + msg_size(). + + You cannot pass the output of to_bytes() to this. to_bytes() is used + to output to the PLM but the modem sends back the same message with + an extra ack byte which this function can read. + + Args: + raw (bytes): The current byte stream to read from. + + Returns: + Returns the constructed OutResetModem object. + """ + assert len(raw) >= cls.fixed_msg_size + assert raw[0] == 0x02 and raw[1] == cls.msg_code + modem_flags = raw[2] + spare1 = raw[3] + spare2 = raw[4] + is_ack = raw[5] == 0x06 + return OutGetModemFlags(is_ack, modem_flags, spare1, spare2) + + #----------------------------------------------------------------------- + def __init__(self, is_ack=None, modem_flags=None, spare1=None, + spare2=None): + """Constructor + + Args: + is_ack (bool): True for ACK, False for NAK. None for output + commands to the modem. + """ + super().__init__() + + self.is_ack = is_ack + self.modem_flags = modem_flags + self.spare1 = spare1 + self.spare2 = spare2 + + #----------------------------------------------------------------------- + def to_bytes(self): + """Convert the message to a byte array. + + Returns: + bytes: Returns the message as bytes. + """ + return bytes([0x02, self.msg_code]) + + #----------------------------------------------------------------------- + def __str__(self): + ack = "" + flags = "" + spares = "" + if self.is_ack is not None: + ack = " ack: %s" % str(self.is_ack) + flags = " modem flags: %s" % str(self.modem_flags) + spares = " spares: %s %s" % (str(self.spare1), str(self.spare2)) + return "OutGetModemFlags%s%s%s" % (flags, spares, ack) + + #----------------------------------------------------------------------- + +#=========================================================================== diff --git a/insteon_mqtt/message/__init__.py b/insteon_mqtt/message/__init__.py index 59868ea3..4b03ba52 100644 --- a/insteon_mqtt/message/__init__.py +++ b/insteon_mqtt/message/__init__.py @@ -52,6 +52,7 @@ from .OutModemLinking import OutModemLinking from .OutModemScene import OutModemScene from .OutResetModem import OutResetModem +from .OutGetModemFlags import OutGetModemFlags from .OutStandard import OutStandard, OutExtended # Hub Messages @@ -85,6 +86,7 @@ 0x69 : OutAllLinkGetFirst, 0x6a : OutAllLinkGetNext, 0x6f : OutAllLinkUpdate, + 0x73 : OutGetModemFlags, # Hub Messages 0x7f : HubRFUnknown, diff --git a/insteon_mqtt/mqtt/Dimmer.py b/insteon_mqtt/mqtt/Dimmer.py index b96d257a..207a1a93 100644 --- a/insteon_mqtt/mqtt/Dimmer.py +++ b/insteon_mqtt/mqtt/Dimmer.py @@ -128,6 +128,6 @@ def _input_set_level(self, client, data, message): # Tell the device to change its level. self.device.set(is_on=is_on, level=level, mode=mode, reason=reason) except: - LOG.error("Invalid dimmer command: %s", data) + LOG.exception("Invalid dimmer command: %s", data) #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/HiddenDoor.py b/insteon_mqtt/mqtt/HiddenDoor.py new file mode 100644 index 00000000..8a26d711 --- /dev/null +++ b/insteon_mqtt/mqtt/HiddenDoor.py @@ -0,0 +1,106 @@ +#=========================================================================== +# +# MQTT Motion sensor device +# +#=========================================================================== +from .. import log +from .BatterySensor import BatterySensor +from .MsgTemplate import MsgTemplate + +LOG = log.get_logger() + + +class HiddenDoor(BatterySensor): + """MQTT interface to an Insteon battery powered hidden door sensor. + + This class connects to a device.HiddenDoor object and converts it's + output state changes to MQTT messages. + + Hidden Door Sensors do support any input commands, but they're sleeping + until activated so they can't respond to commands unless manually awoke. + They also remain awake for a short time after reporting a state change, + so any commands in their queue will be attempted then as well. Hidden + door sensors support everything that battery sensors do with the + addition of reporting their battery voltage, reporting their low battery + threshold level and their configured heart beat interval. + """ + def __init__(self, mqtt, device): + """Constructor + + Args: + mqtt (mqtt.Mqtt): The MQTT main interface. + device (device.Motion): The Insteon object to link to. + """ + super().__init__(mqtt, device) + + self.msg_battery_voltage = MsgTemplate( + topic='insteon/{{address}}/battery_voltage', + payload='{"voltage" : {{batt_volt}}}') + + device.signal_voltage.connect(self._insteon_voltage) + + #----------------------------------------------------------------------- + def load_config(self, config, qos=None): + """Load values from a configuration data object. + + Args: + config (dict: The configuration dictionary to load from. The object + config is stored in config['motion']. + qos (int): The default quality of service level to use. + """ + # Load the BatterySensor configuration. + super().load_config(config, qos) + + data = config.get("hidden_door", None) + if not data: + return + + self.msg_battery_voltage.load_config(data, 'battery_voltage_topic', + 'battery_voltage_payload', qos) + + #----------------------------------------------------------------------- + def template_data_hidden_door(self, is_dawn=None, batt_volt=None, + low_batt_volt=None, hb_interval=None): + """Create the Jinja templating data variables. + + Args: + batt_volt (int): value of battery voltage reported in raw insteon + level + + Returns: + dict: Returns a dict with the variables available for templating. + """ + # Set up the variables that can be used in the templates. + data = { + "address" : self.device.addr.hex, + "name" : self.device.name if self.device.name + else self.device.addr.hex, + } + + if batt_volt is not None: + voltage = int(batt_volt) + data["voltage"] = voltage + + return data + + #----------------------------------------------------------------------- + def _insteon_voltage(self, device, batt_volt): + """Device voltage report callback. + + This is triggered via signal when the Insteon device reports a new + voltage level. It will publish an MQTT message with the new level. + + Args: + device (device.Motion): The Insteon device that changed. + voltage (int): raw insteon voltage level + """ + + LOG.info("MQTT received battery voltage change %s = %s", + device.label, batt_volt) + + # Set up the variables that can be used in the templates. + data = self.template_data_hidden_door() + data["batt_volt"] = batt_volt + self.msg_battery_voltage.publish(self.mqtt, data) + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/IOLinc.py b/insteon_mqtt/mqtt/IOLinc.py index 884dc4c0..92145c1f 100644 --- a/insteon_mqtt/mqtt/IOLinc.py +++ b/insteon_mqtt/mqtt/IOLinc.py @@ -10,7 +10,7 @@ LOG = log.get_logger() -class IOLinc(topic.SetTopic): +class IOLinc(topic.StateTopic, topic.SetTopic): """MQTT interface to an Insteon IOLinc device. This class connects to a device.IOLinc object and converts it's @@ -24,13 +24,9 @@ def __init__(self, mqtt, device): mqtt (mqtt.Mqtt): The MQTT main interface. device (device.IOLinc): The Insteon object to link to. """ - super().__init__(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()}}"}') + super().__init__(mqtt, device, + state_payload='{"sensor":"{{sensor_on_str.lower()}}",' + ' "relay":"{{relay_on_str.lower()}}"}') # Output relay state change reporting template. self.msg_relay_state = MsgTemplate( @@ -42,7 +38,7 @@ def __init__(self, mqtt, device): topic='insteon/{{address}}/sensor', payload='{{sensor_on_str.lower()}}') - device.signal_on_off.connect(self._insteon_on_off) + device.signal_state.connect(self._insteon_on_off) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -57,7 +53,7 @@ def load_config(self, config, qos=None): if not data: return - self.msg_state.load_config(data, 'state_topic', 'state_payload', qos) + self.load_state_data(data, 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', @@ -88,30 +84,53 @@ def unsubscribe(self, link): self.set_unsubscribe(link) #----------------------------------------------------------------------- - def template_data(self, sensor_is_on=None, relay_is_on=None): + def state_template_data(self, **kwargs): """Create the Jinja templating data variables for on/off messages. - Args: + kwargs includes: is_on (bool): The on/off state of the switch. If None, on/off and mode attributes are not added to the data. + mode (on_off.Mode): The on/off mode state. + manual (on_off.Manual): The manual mode state. If None, manual + attributes are not added to the data. + reason (str): The reason the device was triggered. This is an + arbitrary string set into the template variables. + level (int): A brightness level between 0-255 + button (int): Passed to base_template_data, the group numer to use Returns: dict: Returns a dict with the variables available for templating. """ - # Set up the variables that can be used in the templates. - data = self.base_template_data() + data = super().state_template_data(**kwargs) + + button = kwargs.get('button', None) + # Handle_Refresh sends level and not is_on + is_on = kwargs.get('is_on', None) + level = kwargs.get('level', 0x00) + if is_on is None: + is_on = level > 0 + if button is not None: + # I am not very happy about having to query back to the device + # here. But this is needed because when first designing this + # class I allowed a state topic that produced the states of two + # things, the sensor and the relay. Had these been kept in + # different topics this would not be needed. Consider that if + # copying this code. + if button == 1: # This was a sensor emit + sensor_is_on = is_on + relay_is_on = self.device.relay_is_on + elif button == 2: # This was a relay emit + relay_is_on = is_on + sensor_is_on = self.device.sensor_is_on - 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, sensor_is_on, relay_is_on): + def _insteon_on_off(self, device, **kwargs): """Device active on/off callback. This is triggered via signal when the Insteon device goes active or @@ -121,11 +140,10 @@ def _insteon_on_off(self, device, sensor_is_on, relay_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, sensor = %s relay = %s", - device.label, sensor_is_on, relay_is_on) + LOG.info("MQTT received active change %s, %s", + device.label, kwargs) - data = self.template_data(sensor_is_on, relay_is_on) - self.msg_state.publish(self.mqtt, data) + data = self.state_template_data(**kwargs) self.msg_relay_state.publish(self.mqtt, data) self.msg_sensor_state.publish(self.mqtt, data) diff --git a/insteon_mqtt/mqtt/KeypadLinc.py b/insteon_mqtt/mqtt/KeypadLinc.py index a59f837c..0798c81b 100644 --- a/insteon_mqtt/mqtt/KeypadLinc.py +++ b/insteon_mqtt/mqtt/KeypadLinc.py @@ -1,11 +1,9 @@ #=========================================================================== # -# MQTT keypad linc which is a dimmer plus 4 or 8 button remote. +# MQTT keypad linc with 4 or 8 button # #=========================================================================== from .. import log -from .MsgTemplate import MsgTemplate -from . import util from . import topic LOG = log.get_logger() @@ -13,40 +11,25 @@ class KeypadLinc(topic.SetTopic, topic.SceneTopic, topic.StateTopic, topic.ManualTopic): - """MQTT interface to an Insteon KeypadLinc dimmer or switch. + """MQTT interface to an Insteon KeypadLinc switch. This class connects to a device.KeypadLinc object and converts it's output state changes to MQTT messages. It also subscribes to topics to allow input MQTT messages to change the state of the Insteon device. - - KeypadLinc are either dimmers or switches for the main load and switches - for the other buttons on the device. """ - def __init__(self, mqtt, device): + def __init__(self, mqtt, device, **kwargs): """Constructor Args: mqtt (mqtt.Mqtt): The MQTT main interface. device (device.KeypadLinc): The Insteon object to link to. + kwargs (dict): Additional settings from the KPL Dimmer """ - # Setup a special template for button one if this is a dimmer - state_payload_1 = None - if device.is_dimmer: - state_payload_1 = '{ "state" : "{{on_str.lower()}}", ' \ - '"brightness" : {{level_255}} }' super().__init__(mqtt, device, scene_topic='insteon/{{address}}/scene/{{button}}', state_topic='insteon/{{address}}/state/{{button}}', - state_payload_1=state_payload_1, - set_topic='insteon/{{address}}/set/{{button}}') - - self.msg_dimmer_level = None - if self.device.is_dimmer: - # Input dimmer level command template. - self.msg_dimmer_level = MsgTemplate( - topic='insteon/{{address}}/level', - payload='{ "cmd" : "{{json.state.lower()}}", ' - '"level" : {{json.brightness}} }') + set_topic='insteon/{{address}}/set/{{button}}', + **kwargs) #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -64,7 +47,9 @@ def load_config(self, config, qos=None): # Load the various topics self.load_state_data(data, qos, topic='btn_state_topic', - payload='btn_state_payload') + payload='btn_state_payload', + topic_1='dimmer_state_topic', + payload_1='dimmer_state_payload') self.load_manual_data(data, qos) self.load_scene_data(data, qos, topic='btn_scene_topic', @@ -74,12 +59,8 @@ def load_config(self, config, qos=None): topic='btn_on_off_topic', payload='btn_on_off_payload') - if self.device.is_dimmer: - self.msg_dimmer_level.load_config(data, 'dimmer_level_topic', - 'dimmer_level_payload', qos) - #----------------------------------------------------------------------- - def subscribe(self, link, qos): + def subscribe(self, link, qos, start_group=1): """Subscribe to any MQTT topics the object needs. Subscriptions are used when the object has things that can be @@ -89,32 +70,6 @@ def subscribe(self, link, qos): link (network.Mqtt): The MQTT network client to use. qos (int): The quality of service to use. """ - # For dimmers, the button 1 set can be either an on/off or a dimming - # command. And the dimmer topic might have the same topic as the - # on/off command. - start_group = 1 - if self.device.is_dimmer: - start_group = 2 - - # If the on/off and level topics are the same, send to level - # otherwise instantiate both. - data = self.base_template_data(button=1) - topic_switch = self.msg_set.render_topic(data) - topic_dimmer = self.msg_dimmer_level.render_topic(data) - if topic_switch == topic_dimmer: - data = self.base_template_data(button=1) - topic_dimmer = self.msg_dimmer_level.render_topic(data) - link.subscribe(topic_dimmer, qos, self._input_set_level) - else: - self.set_subscribe(link, qos, group=1) - # Create the topic names for button 1. - data = self.base_template_data(button=1) - topic_dimmer = self.msg_dimmer_level.render_topic(data) - link.subscribe(topic_dimmer, qos, self._input_set_level) - - # Add the Scene Topic - self.scene_subscribe(link, qos, group=1) - # We need to subscribe to each button topic so we know which one is # which. for group in range(start_group, 9): @@ -131,48 +86,3 @@ def unsubscribe(self, link): for group in range(1, 9): self.set_unsubscribe(link, group=group) self.scene_unsubscribe(link, group=group) - - if self.device.is_dimmer: - data = self.base_template_data(button=1) - topic_str = self.msg_dimmer_level.render_topic(data) - link.unsubscribe(topic_str) - - #----------------------------------------------------------------------- - def _input_set_level(self, client, data, message, raise_errors=False): - """Handle an input level change MQTT message. - - This is called when we receive a message on the level change 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. - raise_errors (bool): True to raise any errors - otherwise they - are logged and ignored. - """ - LOG.info("KeypadLinc message %s %s", message.topic, message.payload) - assert self.msg_dimmer_level is not None - - data = self.msg_dimmer_level.to_json(message.payload) - if not data: - return - - LOG.info("KeypadLinc input command: %s", data) - level_str = data.get('level', None) - if level_str is None or level_str == "": - # Dimmer and command topic can be the same - # If this lacks a level command it is meant for on/off - self._input_set(client, data, message) - else: - try: - is_on, mode, transition = util.parse_on_off(data) - level = '0' if not is_on else data.get('level', None) - if level is not None: - level = int(level) - reason = data.get("reason", "") - self.device.set(is_on=is_on, level=level, mode=mode, - reason=reason, transition=transition) - except: - LOG.error("Invalid KeypadLinc level command: %s", data) diff --git a/insteon_mqtt/mqtt/KeypadLincDimmer.py b/insteon_mqtt/mqtt/KeypadLincDimmer.py new file mode 100644 index 00000000..954ab1aa --- /dev/null +++ b/insteon_mqtt/mqtt/KeypadLincDimmer.py @@ -0,0 +1,147 @@ +#=========================================================================== +# +# MQTT keypad linc which is a dimmer with 4 or 8 button +# +#=========================================================================== +from .. import log +from .MsgTemplate import MsgTemplate +from . import util +from .KeypadLinc import KeypadLinc + +LOG = log.get_logger() + + +class KeypadLincDimmer(KeypadLinc): + """MQTT interface to an Insteon KeypadLinc dimmer. + + This class connects to a device.KeypadLinc object and converts it's output + state changes to MQTT messages. It also subscribes to topics to allow + input MQTT messages to change the state of the Insteon device. + + This class is an extension of the KeypadLinc switch class + """ + def __init__(self, mqtt, device): + """Constructor + + Args: + mqtt (mqtt.Mqtt): The MQTT main interface. + device (device.KeypadLinc): The Insteon object to link to. + """ + # Setup a special template for button one if this is a dimmer + state_payload_1 = '{ "state" : "{{on_str.lower()}}", ' \ + '"brightness" : {{level_255}} }' + super().__init__(mqtt, device, state_payload_1=state_payload_1) + + # Input dimmer level command template. + self.msg_dimmer_level = MsgTemplate( + topic='insteon/{{address}}/level', + payload='{ "cmd" : "{{json.state.lower()}}", ' + '"level" : {{json.brightness}} }') + + #----------------------------------------------------------------------- + def load_config(self, config, qos=None): + """Load values from a configuration data object. + + Args: + config (dict: The configuration dictionary to load from. The object + config is stored in config['keypad_linc']. + qos (int): The default quality of service level to use. + """ + data = config.get("keypad_linc", None) + if not data: + return + super().load_config(config, qos=qos) + self.msg_dimmer_level.load_config(data, 'dimmer_level_topic', + 'dimmer_level_payload', qos) + + #----------------------------------------------------------------------- + def subscribe(self, link, qos, start_group=1): + """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. + """ + # For dimmers, the button 1 set can be either an on/off or a dimming + # command. And the dimmer topic might have the same topic as the + # on/off command. + start_group = 2 + + # If the on/off and level topics are the same, send to level + # otherwise instantiate both. + data = self.base_template_data(button=1) + topic_switch = self.msg_set.render_topic(data) + topic_dimmer = self.msg_dimmer_level.render_topic(data) + if topic_switch == topic_dimmer: + data = self.base_template_data(button=1) + topic_dimmer = self.msg_dimmer_level.render_topic(data) + link.subscribe(topic_dimmer, qos, self._input_set_level) + else: + self.set_subscribe(link, qos, group=1) + # Create the topic names for button 1. + data = self.base_template_data(button=1) + topic_dimmer = self.msg_dimmer_level.render_topic(data) + link.subscribe(topic_dimmer, qos, self._input_set_level) + + # Add the Scene Topic + self.scene_subscribe(link, qos, group=1) + + # We need to subscribe to each button topic so we know which one is + # which. + super().subscribe(link, qos, start_group=start_group) + + #----------------------------------------------------------------------- + def unsubscribe(self, link): + """Unsubscribe to any MQTT topics the object was subscribed to. + + Args: + link (network.Mqtt): The MQTT network client to use. + """ + super().unsubscribe(link) + + data = self.base_template_data(button=1) + topic_str = self.msg_dimmer_level.render_topic(data) + link.unsubscribe(topic_str) + + #----------------------------------------------------------------------- + def _input_set_level(self, client, data, message, raise_errors=False): + """Handle an input level change MQTT message. + + This is called when we receive a message on the level change 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. + raise_errors (bool): True to raise any errors - otherwise they + are logged and ignored. + """ + LOG.info("KeypadLinc message %s %s", message.topic, message.payload) + assert self.msg_dimmer_level is not None + + data = self.msg_dimmer_level.to_json(message.payload) + if not data: + return + + LOG.info("KeypadLinc input command: %s", data) + level_str = data.get('level', None) + if level_str is None or level_str == "": + # Dimmer and command topic can be the same + # If this lacks a level command it is meant for on/off + self._input_set(client, data, message) + else: + try: + is_on, mode, transition = util.parse_on_off(data) + level = '0' if not is_on else data.get('level', None) + if level is not None: + level = int(level) + reason = data.get("reason", "") + self.device.set(is_on=is_on, level=level, mode=mode, + reason=reason, transition=transition) + except: + LOG.error("Invalid KeypadLinc level command: %s", data) diff --git a/insteon_mqtt/mqtt/Leak.py b/insteon_mqtt/mqtt/Leak.py index ceca6fd3..0fd524e0 100644 --- a/insteon_mqtt/mqtt/Leak.py +++ b/insteon_mqtt/mqtt/Leak.py @@ -5,7 +5,6 @@ #=========================================================================== from .. import log from .BatterySensor import BatterySensor -from .MsgTemplate import MsgTemplate LOG = log.get_logger() @@ -24,16 +23,9 @@ def __init__(self, mqtt, device): mqtt (mqtt.Mqtt): The MQTT main interface. device (device.Leak): The Insteon object to link to. """ - super().__init__(mqtt, device) - - # Default values for the topics. - self.msg_wet = MsgTemplate( - topic='insteon/{{address}}/wet', - payload='{{is_wet_str.lower()}}') - - # Connect the two signals from the insteon device so we get notified - # of changes. - device.signal_wet.connect(self._insteon_wet) + super().__init__(mqtt, device, + state_topic='insteon/{{address}}/wet', + state_payload='{{is_wet_str.lower()}}') #----------------------------------------------------------------------- def load_config(self, config, qos=None): @@ -51,7 +43,9 @@ def load_config(self, config, qos=None): if not data: return - self.msg_wet.load_config(data, 'wet_dry_topic', 'wet_dry_payload', qos) + # Load the various topics + self.load_state_data(data, qos, topic='wet_dry_topic', + payload='wet_dry_payload') # In versions <= 0.7.2, this was in leak sensor so try and # load them to insure old config files still work. @@ -60,44 +54,44 @@ def load_config(self, config, qos=None): 'heartbeat_payload', qos) #----------------------------------------------------------------------- - def template_data_leak(self, is_wet=None): - """Create the Jinja templating data variables. - - Args: - is_wet (bool): Is the device wet or not. If this is None, wet/dry - attributes are not added to the data. - is_heartbeat (bool): Is the heartbeat active nor not. If this is - None, heartbeat attributes are not added to the data. + def state_template_data(self, **kwargs): + """Create the Jinja templating data variables for on/off messages. + + kwargs includes: + is_on (bool): The on/off state of the switch. If None, on/off and + mode attributes are not added to the data. + mode (on_off.Mode): The on/off mode state. + manual (on_off.Manual): The manual mode state. If None, manual + attributes are not added to the data. + reason (str): The reason the device was triggered. This is an + arbitrary string set into the template variables. + level (int): A brightness level between 0-255 + button (int): Passed to base_template_data, the group numer to use Returns: dict: Returns a dict with the variables available for templating. """ - # Set up the variables that can be used in the templates. - data = self.base_template_data() - - if is_wet is not None: - data["is_wet"] = 1 if is_wet else 0 - data["is_wet_str"] = "on" if is_wet else "off" - data["is_dry"] = 0 if is_wet else 1 - data["is_dry_str"] = "off" if is_wet else "on" - data["state"] = "wet" if is_wet else "dry" + data = super().state_template_data(**kwargs) + + # Handle_Refresh sends level and not is_on + is_on = kwargs.get('is_on', None) + level = kwargs.get('level', 0x00) + if is_on is None: + is_on = level > 0 + + # Distinguish wet vs dry by the group + button = kwargs.get('button', None) + GROUP_WET = 2 + is_wet = False # Assume dry by default + # If GROUP_DRY sent the message this will be default to not is_wet + # already + if button == GROUP_WET: + is_wet = is_on + + data["is_wet"] = 1 if is_wet else 0 + data["is_wet_str"] = "on" if is_wet else "off" + data["is_dry"] = 0 if is_wet else 1 + data["is_dry_str"] = "off" if is_wet else "on" + data["state"] = "wet" if is_wet else "dry" return data - - #----------------------------------------------------------------------- - def _insteon_wet(self, device, is_wet): - """Device wet/dry on/off callback. - - This is triggered via signal when the Insteon device detects - wet or dry. It will publish an MQTT message with the new - state. - - Args: - device (device.Leak): The Insteon device that changed. - is_wet (bool): True for wet, False for dry. - """ - LOG.info("MQTT received wet/dry change %s wet= %s", device.label, - is_wet) - - data = self.template_data_leak(is_wet) - self.msg_wet.publish(self.mqtt, data) diff --git a/insteon_mqtt/mqtt/Mqtt.py b/insteon_mqtt/mqtt/Mqtt.py index d047bc76..2aeda95a 100644 --- a/insteon_mqtt/mqtt/Mqtt.py +++ b/insteon_mqtt/mqtt/Mqtt.py @@ -6,6 +6,7 @@ import functools import json import logging +import insteon_mqtt from .. import log from . import config from .MsgTemplate import MsgTemplate diff --git a/insteon_mqtt/mqtt/__init__.py b/insteon_mqtt/mqtt/__init__.py index 8d7045e0..15bb3c68 100644 --- a/insteon_mqtt/mqtt/__init__.py +++ b/insteon_mqtt/mqtt/__init__.py @@ -22,8 +22,10 @@ from .Dimmer import Dimmer from .EZIO4O import EZIO4O from .FanLinc import FanLinc +from .HiddenDoor import HiddenDoor from .IOLinc import IOLinc from .KeypadLinc import KeypadLinc +from .KeypadLincDimmer import KeypadLincDimmer from .Leak import Leak from .Modem import Modem from .Motion import Motion diff --git a/insteon_mqtt/mqtt/config.py b/insteon_mqtt/mqtt/config.py index 0b2e361f..2a8a484c 100644 --- a/insteon_mqtt/mqtt/config.py +++ b/insteon_mqtt/mqtt/config.py @@ -17,8 +17,10 @@ from .Dimmer import Dimmer from .EZIO4O import EZIO4O from .FanLinc import FanLinc +from .HiddenDoor import HiddenDoor from .IOLinc import IOLinc from .KeypadLinc import KeypadLinc +from .KeypadLincDimmer import KeypadLincDimmer from .Leak import Leak from .Modem import Modem as MqttModem from .Motion import Motion @@ -35,8 +37,10 @@ device.Dimmer : Dimmer, device.EZIO4O : EZIO4O, device.FanLinc : FanLinc, + device.HiddenDoor : HiddenDoor, device.IOLinc : IOLinc, device.KeypadLinc : KeypadLinc, + device.KeypadLincDimmer : KeypadLincDimmer, device.Leak : Leak, device.Motion : Motion, device.Outlet : Outlet, diff --git a/insteon_mqtt/mqtt/topic/SetTopic.py b/insteon_mqtt/mqtt/topic/SetTopic.py index 94d3661c..5e3132f3 100644 --- a/insteon_mqtt/mqtt/topic/SetTopic.py +++ b/insteon_mqtt/mqtt/topic/SetTopic.py @@ -117,6 +117,6 @@ def _input_set(self, client, data, message, group=0x01): self.device.set(is_on=is_on, level=level, group=group, mode=mode, transition=transition, reason=reason) except: - LOG.error("Invalid SetTopic command: %s", data) + LOG.exception("Invalid SetTopic command: %s", data) #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/topic/StateTopic.py b/insteon_mqtt/mqtt/topic/StateTopic.py index 8673d8ca..3ea06cf0 100644 --- a/insteon_mqtt/mqtt/topic/StateTopic.py +++ b/insteon_mqtt/mqtt/topic/StateTopic.py @@ -143,7 +143,6 @@ def state_template_data(self, **kwargs): # Update with manual data manual_data = ManualTopic.manual_template_data(**kwargs) data.update(manual_data) - return data #----------------------------------------------------------------------- @@ -171,9 +170,8 @@ def publish_state(self, device, **kwargs): data = self.state_template_data(**kwargs) # If this has a distinct template for group 1 use it. - if ('button' in kwargs and - kwargs['button'] == 1 and - self.msg_state_1 is not None): + button = kwargs.get('button', None) + if (button == 1 or button is None) and self.msg_state_1 is not None: self.msg_state_1.publish(self.mqtt, data, retain=retain) else: self.msg_state.publish(self.mqtt, data, retain=retain) diff --git a/insteon_mqtt/network/Mqtt.py b/insteon_mqtt/network/Mqtt.py index 2e033e7d..44622ee9 100644 --- a/insteon_mqtt/network/Mqtt.py +++ b/insteon_mqtt/network/Mqtt.py @@ -59,8 +59,19 @@ def __init__(self, host="127.0.0.1", port=1883, id=None, self._reconnect_dt = reconnect_dt self._fd = None - # Create the MQTT client and set the callbacks to our methods. - self.client = paho.Client(client_id=self.id, clean_session=False) + self.setup_client() + + #----------------------------------------------------------------------- + def setup_client(self): + """ Create or reinitialise the MQTT client and set the callbacks. + """ + + client_args = {'client_id': self.id, 'clean_session': False} + + if not hasattr(self, 'client'): + self.client = paho.Client(**client_args) + else: + self.client.reinitialise(**client_args) self.client.on_connect = self._on_connect self.client.on_disconnect = self._on_disconnect self.client.on_message = self._on_message @@ -91,7 +102,7 @@ def load_config(self, config): id = config.get("id") if id is not None: self.id = id - self.client.reinitialise(client_id=self.id, clean_session=False) + self.setup_client() username = config.get('username', None) if username is not None: diff --git a/insteon_mqtt/util.py b/insteon_mqtt/util.py index 048fbd72..b91758f4 100644 --- a/insteon_mqtt/util.py +++ b/insteon_mqtt/util.py @@ -201,7 +201,7 @@ def input_bool(inputs, field): # only true/false or 1/0 is allowed. return bool(int(value)) except ValueError: - msg = "Invalid %s input. Valid inputs are 1/0 or True/False" % input + msg = "Invalid %s input. Valid inputs are 1/0 or True/False" % field LOG.exception(msg) @@ -239,7 +239,7 @@ def input_integer(inputs, field): else: return int(value) except ValueError: - msg = "Invalid %s input. Valid inputs are integer values." % input + msg = "Invalid %s input. Valid inputs are integer values." % field LOG.exception(msg) @@ -281,7 +281,7 @@ def input_byte(inputs, field): return v except ValueError: - msg = "Invalid %s input. Valid inputs are 0-255" % input + msg = "Invalid %s input. Valid inputs are 0-255" % field LOG.exception(msg) @@ -314,5 +314,5 @@ def input_float(inputs, field): else: return float(value) except ValueError: - msg = "Invalid %s input. Valid inputs are float values." % input + msg = "Invalid %s input. Valid inputs are float values." % field LOG.exception(msg) diff --git a/setup.py b/setup.py index 8c895652..b92edf07 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setuptools.setup( name = 'insteon-mqtt', - version = '0.7.6', + version = '0.8.0', description = "Insteon <-> MQTT bridge server", long_description = readme, author = "Ted Drain", diff --git a/tests/db/test_DeviceEntry.py b/tests/db/test_DeviceEntry.py index 93685a8c..3b11fda7 100644 --- a/tests/db/test_DeviceEntry.py +++ b/tests/db/test_DeviceEntry.py @@ -75,9 +75,9 @@ def test_label(self): modem = MockModem() addr1 = IM.Address(0x03, 0x04, 0x05) addr = IM.Address('12.34.ab') - device1 = IM.device.Base(protocol, modem, addr1) + device1 = IM.device.base.Base(protocol, modem, addr1) obj = IM.db.DeviceEntry(addr, 0x03, mem_loc, ctrl, data, db=device1.db) - device = IM.device.Base(protocol, modem, addr, name="Awesomesauce") + device = IM.device.base.Base(protocol, modem, addr, name="Awesomesauce") modem.set_linked_device(device) assert obj.label == "12.34.ab (Awesomesauce)" diff --git a/tests/db/test_DeviceModifyManagerI1.py b/tests/db/test_DeviceModifyManagerI1.py index b99a3915..125c15e7 100644 --- a/tests/db/test_DeviceModifyManagerI1.py +++ b/tests/db/test_DeviceModifyManagerI1.py @@ -15,7 +15,7 @@ def test_start_modify(self): protocol = MockProto() modem = MockModem() addr = IM.Address(0x01, 0x02, 0x03) - device = IM.device.Base(protocol, modem, addr) + device = IM.device.base.Base(protocol, modem, addr) # Create the new entry at the current last memory location. db_flags = Msg.DbFlags(in_use=True, is_controller=True, @@ -38,7 +38,7 @@ def test_start_modify_with_on_done(self): protocol = MockProto() modem = MockModem() addr = IM.Address(0x01, 0x02, 0x03) - device = IM.device.Base(protocol, modem, addr) + device = IM.device.base.Base(protocol, modem, addr) # Create the new entry at the current last memory location. db_flags = Msg.DbFlags(in_use=True, is_controller=True, @@ -65,7 +65,7 @@ def test_handle_set_msb(self): protocol = MockProto() modem = MockModem() addr = IM.Address(0x01, 0x02, 0x03) - device = IM.device.Base(protocol, modem, addr) + device = IM.device.base.Base(protocol, modem, addr) # Create the new entry at the current last memory location. db_flags = Msg.DbFlags(in_use=True, is_controller=True, @@ -97,7 +97,7 @@ def test_handle_lsb_response(self): protocol = MockProto() modem = MockModem() addr = IM.Address(0x01, 0x02, 0x03) - device = IM.device.Base(protocol, modem, addr) + device = IM.device.base.Base(protocol, modem, addr) # Create the new entry at the current last memory location. db_flags = Msg.DbFlags(in_use=True, is_controller=True, @@ -129,7 +129,7 @@ def test_handle_write_lsb_response(self): protocol = MockProto() modem = MockModem() addr = IM.Address(0x01, 0x02, 0x03) - device = IM.device.Base(protocol, modem, addr) + device = IM.device.base.Base(protocol, modem, addr) # Create the new entry at the current last memory location. db_flags = Msg.DbFlags(in_use=True, is_controller=True, @@ -154,7 +154,7 @@ def test_finish_write(self): protocol = MockProto() modem = MockModem() addr = IM.Address(0x01, 0x02, 0x03) - device = IM.device.Base(protocol, modem, addr) + device = IM.device.base.Base(protocol, modem, addr) calls = [] def callback(success, msg, data): calls.append(msg) diff --git a/tests/db/test_ModemEntry.py b/tests/db/test_ModemEntry.py index a9bac552..d0209393 100644 --- a/tests/db/test_ModemEntry.py +++ b/tests/db/test_ModemEntry.py @@ -87,7 +87,7 @@ def test_label(self): db = MockDB(modem) addr = IM.Address(0x03, 0x04, 0x05) obj = IM.db.ModemEntry(addr, 0x03, False, data, db=db) - device = IM.device.Base(protocol, modem, addr, name="Awesomesauce") + device = IM.device.base.Base(protocol, modem, addr, name="Awesomesauce") modem.set_linked_device(device) assert obj.label == "03.04.05 (Awesomesauce)" diff --git a/tests/device/base/test_BaseDev.py b/tests/device/base/test_BaseDev.py new file mode 100644 index 00000000..33f0b389 --- /dev/null +++ b/tests/device/base/test_BaseDev.py @@ -0,0 +1,514 @@ +#=========================================================================== +# +# Tests for: insteont_mqtt/device/Base.py +# +# pylint: disable=W0621,W0212, +# +#=========================================================================== +import logging +from pathlib import Path +# from pprint import pprint +from unittest import mock +from unittest.mock import call +import pytest +import insteon_mqtt as IM +from insteon_mqtt.device.base.Base import Base +import insteon_mqtt.message as Msg +import insteon_mqtt.util as util +import helpers as H + +@pytest.fixture +def test_device(tmpdir): + ''' + Returns a generically configured device for testing + ''' + protocol = H.main.MockProtocol() + modem = H.main.MockModem(tmpdir) + modem.db = IM.db.Modem(None, modem) + modem.scenes = IM.Scenes.SceneManager(modem, None) + addr = IM.Address(0x01, 0x02, 0x03) + device = Base(protocol, modem, addr) + return device + +@pytest.fixture +def test_device_2(tmpdir): + ''' + Returns a generically configured device for testing + ''' + protocol = H.main.MockProtocol() + modem = H.main.MockModem(tmpdir) + modem.db = IM.db.Modem(None, modem) + modem.scenes = IM.Scenes.SceneManager(modem, None) + addr = IM.Address(0x56, 0x78, 0xcd) + device = Base(protocol, modem, addr) + return device + +@pytest.fixture +def test_entry_1(): + addr = IM.Address('12.34.ab') + data = bytes([0xff, 0x00, 0x00]) + group = 0x01 + in_use = True + is_controller = True + is_last_rec = False + db_flags = Msg.DbFlags(in_use, is_controller, is_last_rec) + mem_loc = 1 + return IM.db.DeviceEntry(addr, group, mem_loc, db_flags, data) + +@pytest.fixture +def test_entry_2(): + addr = IM.Address('56.78.cd') + data = bytes([0xff, 0x00, 0x00]) + group = 0x01 + in_use = True + is_controller = True + is_last_rec = False + db_flags = Msg.DbFlags(in_use, is_controller, is_last_rec) + mem_loc = 1 + return IM.db.DeviceEntry(addr, group, mem_loc, db_flags, data) + +@pytest.fixture +def test_entry_3(): + addr = IM.Address('56.78.cd') + data = bytes([0xff, 0x00, 0x00]) + group = 0x01 + in_use = True + is_controller = False + is_last_rec = False + db_flags = Msg.DbFlags(in_use, is_controller, is_last_rec) + mem_loc = 1 + return IM.db.DeviceEntry(addr, group, mem_loc, db_flags, data) + +class Test_Base_Config(): + def test_type(self, test_device): + assert test_device.type() == "base" + + def test_no_name(self, test_device): + protocol = test_device.protocol + modem = test_device.modem + #address is intentionally badly formatted + device = Base.from_config(["3 2.34:56"], protocol, modem) + assert device + + def test_with_name(self, test_device): + protocol = test_device.protocol + modem = test_device.modem + #address is intentionally badly formatted + device = Base.from_config([{"32 34 56": 'test'}], protocol, modem) + assert device + + def test_info_entry(self, test_device): + assert test_device.info_entry() == {'01.02.03': + {'label': None, + 'type': 'base'} + } + + def test_print_db(self, test_device): + # This just prints an output just make sure we don't crash + test_device.print_db(util.make_callback(None)) + assert True + + def test_pair(self, test_device): + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + test_device.pair() + calls = [ + call(test_device.refresh), + ] + mocked.assert_has_calls(calls) + assert mocked.call_count == 1 + + def test_broadcast(self, test_device, caplog): + # test broadcast Messages, Base doesn't handle any + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, 0x11, 0x00) + test_device.handle_broadcast(msg) + assert "has no handler for broadcast" in caplog.text + + def test_join(self, test_device): + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + test_device.join() + calls = [call(test_device.get_engine), + call(test_device._join_device)] + mocked.assert_has_calls(calls, any_order=True) + + def test_join_device(self, test_device): + test_device.db.engine = 1 + def on_done(success, msg, data): + assert success + assert msg == 'Operation Complete.' + test_device._join_device(on_done=on_done) + + test_device.db.engine = 2 + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + def fake_link(self): + pass + test_device.modem.linking = fake_link + test_device._join_device() + calls = [call(test_device.modem.linking), + call(test_device.linking)] + # Must be in this order to get the proper ctrl/resp orientation + mocked.assert_has_calls(calls, any_order=False) + + def test_device_linking(self, test_device): + msg = Msg.OutExtended.direct(test_device.addr, + Msg.CmdType.LINKING_MODE, 0x01, + bytes([0x00] * 14)) + test_device.linking() + sent = test_device.protocol.sent + assert len(sent) == 1 + assert sent[0].msg.to_bytes() == msg.to_bytes() + + def test_get_flags(self, test_device): + msg = Msg.OutStandard.direct(test_device.addr, + Msg.CmdType.GET_OPERATING_FLAGS, 0x00) + test_device.get_flags() + sent = test_device.protocol.sent + assert len(sent) == 1 + assert sent[0].msg.to_bytes() == msg.to_bytes() + + def test_get_engine(self, test_device): + msg = Msg.OutStandard.direct(test_device.addr, + Msg.CmdType.GET_ENGINE_VERSION, 0x00) + test_device.get_engine() + sent = test_device.protocol.sent + assert len(sent) == 1 + assert sent[0].msg.to_bytes().hex() == msg.to_bytes().hex() + assert isinstance(sent[0].handler, IM.handler.StandardCmdNAK) + + def test_handle_group(self, test_device, caplog): + with caplog.at_level(logging.DEBUG): + test_device.handle_group_cmd(None, None) + assert 'ignoring group cmd - not implemented' in caplog.text + + def test_sync_dry_ref(self, test_device): + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + test_device.sync(dry_run=True, refresh=True) + assert mocked.call_count == 2 + call_args = mocked.call_args_list + assert call_args[0].args == (test_device.refresh,) + assert call_args[1].args == (test_device.sync, True) + assert not call_args[1].kwargs['refresh'] + + def test_sync_dry_no_ref(self, test_device, test_entry_1, test_entry_2): + # Mock up a DB and DB_Config with differences, see if sync properly + # marks these diffs as needing an update. + test_device.db.add_entry(test_entry_1) + test_device.db_config = IM.db.Device(test_device.addr, None, + test_device) + test_device.db_config.add_entry(test_entry_2) + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + test_device.sync(dry_run=True, refresh=False) + assert mocked.call_count == 2 + call_args = mocked.call_args_list + assert call_args[0].args == (test_device._sync_del, test_entry_1, + True) + assert call_args[1].args == (test_device._sync_add, test_entry_2, + True) + + def test_sync_del_dry(self, test_device, test_entry_1): + def on_done(success, msg, data): + assert success + test_device._sync_del(test_entry_1, True, on_done=on_done) + + def test_sync_del(self, test_device, test_entry_1): + with mock.patch.object(test_device.db, 'delete_on_device') as mocked: + test_device._sync_del(test_entry_1, False) + mocked.assert_called_once_with(test_entry_1, on_done=None) + + def test_sync_add_dry(self, test_device, test_entry_1): + def on_done(success, msg, data): + assert success + test_device._sync_add(test_entry_1, True, on_done=on_done) + + def test_sync_add(self, test_device, test_entry_1): + with mock.patch.object(test_device.db, 'add_on_device') as mocked: + test_device._sync_add(test_entry_1, False) + mocked.assert_called_once_with(test_entry_1.addr, + test_entry_1.group, + test_entry_1.is_controller, + test_entry_1.data, on_done=None) + + def test_import_scenes_dry(self, test_device, test_entry_1): + test_device.db.add_entry(test_entry_1) + test_device.db_config = IM.db.Device(test_device.addr, None, + test_device) + test_device.import_scenes() + # Dry Run, nothing changed + assert test_device.modem.scenes.data == [] + + def test_import_scenes(self, test_device, test_entry_1): + test_device.db.add_entry(test_entry_1) + test_device.db_config = IM.db.Device(test_device.addr, None, + test_device) + test_device.import_scenes(dry_run=False) + assert test_device.modem.scenes.data == [{'controllers': + [{'01.02.03': + {'data_1': 255, + 'data_3': 0}}], + 'responders': + ['12.34.ab']}] + + def test_import_scenes_none(self, test_device, test_entry_1): + test_device.db_config = IM.db.Device(test_device.addr, None, + test_device) + test_device.import_scenes(dry_run=False) + assert test_device.modem.scenes.data == [] + + def test_db_add_ctrl(self, test_device): + with mock.patch.object(test_device, '_db_update') as mocked: + local_group = 0x01 + remote_addr = IM.Address('01.02.03') + remote_group = 0x01 + test_device.db_add_ctrl_of(local_group, remote_addr, remote_group) + mocked.assert_called_once_with(local_group, True, remote_addr, + remote_group, True, + True, None, + None, None) + + def test_db_add_resp(self, test_device): + with mock.patch.object(test_device, '_db_update') as mocked: + local_group = 0x01 + remote_addr = IM.Address('01.02.03') + remote_group = 0x01 + test_device.db_add_resp_of(local_group, remote_addr, remote_group) + mocked.assert_called_once_with(local_group, False, remote_addr, + remote_group, True, + True, None, + None, None) + + def test_db_del_ctrl(self, test_device): + with mock.patch.object(test_device, '_db_delete') as mocked: + group = 0x01 + addr = IM.Address('01.02.03') + test_device.db_del_ctrl_of(addr, group) + mocked.assert_called_once_with(addr, group, True, True, + True, None) + + def test_db_del_resp(self, test_device): + with mock.patch.object(test_device, '_db_delete') as mocked: + group = 0x01 + addr = IM.Address('01.02.03') + test_device.db_del_resp_of(addr, group) + mocked.assert_called_once_with(addr, group, False, True, + True, None) + + def test_run_cmd_empty(self, test_device, caplog): + test_device.run_command() + assert 'Invalid command sent to device' in caplog.text + + def test_run_cmd_bad_cmd(self, test_device, caplog): + test_device.run_command(cmd="NoSuchThing") + assert 'Invalid command sent to device' in caplog.text + + def test_run_cmd_missing_arg(self, test_device, caplog): + test_device.run_command(cmd='print_db') + assert 'Invalid command inputs to device' in caplog.text + + def test_run_cmd_good(self, test_device, caplog): + def on_done(success, msg, data): + assert success + test_device.run_command(cmd='print_db', on_done=on_done) + + def test_handle_flags(self, test_device, caplog): + def on_done(success, msg, data): + assert success + assert msg == "Operation complete" + flags = Msg.Flags(Msg.Flags.Type.DIRECT, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.GET_OPERATING_FLAGS, 0x55) + with caplog.at_level(logging.DEBUG): + test_device.handle_flags(msg, on_done=on_done) + assert 'operating flags: 01010101' in caplog.text + + def test_handle_engine_nak(self, test_device, caplog): + def on_done(success, msg, data): + assert success + assert msg == "Operation complete" + flags = Msg.Flags(Msg.Flags.Type.DIRECT_NAK, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.GET_ENGINE_VERSION, 0x00) + with caplog.at_level(logging.DEBUG): + test_device.handle_engine(msg, on_done=on_done) + assert 'sent NAK to get engine' in caplog.text + assert test_device.db.engine == 2 + + def test_handle_model(self, test_device, caplog): + def on_done(success, msg, data): + assert success + assert msg == "Operation complete" + flags = Msg.Flags(Msg.Flags.Type.BROADCAST, False) + to_addr = IM.Address(0x01, 0x30, 0x45) + from_addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(from_addr, to_addr, flags, 0x01, 0x00) + with caplog.at_level(logging.DEBUG): + test_device.handle_model(msg, on_done=on_done) + assert 'received model information' in caplog.text + assert '2476D' in test_device.db.desc.model + + def test_handle_model_bad_resp(self, test_device, caplog): + def on_done(success, msg, data): + assert not success + assert "Operation failed" in msg + flags = Msg.Flags(Msg.Flags.Type.BROADCAST, False) + to_addr = IM.Address(0x01, 0x30, 0x45) + from_addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(from_addr, to_addr, flags, 0x05, 0x00) + with caplog.at_level(logging.DEBUG): + test_device.handle_model(msg, on_done=on_done) + assert 'get_model response with wrong cmd' in caplog.text + + def test_update_linked_devices(self, test_device, test_entry_1, + test_entry_2, test_device_2, caplog): + test_device.db.add_entry(test_entry_1) + test_device.db.add_entry(test_entry_2) + test_device.modem.add(test_device_2) + test_device.db_config = IM.db.Device(test_device.addr, None, + test_device) + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, 0x11, 0x00) + with caplog.at_level(logging.DEBUG): + test_device.update_linked_devices(msg) + assert 'device 12.34.ab is not in config' in caplog.text + assert 'Device 56.78.cd ignoring group cmd' in caplog.text + + def test_db_update(self, test_device, test_entry_2, + test_device_2, caplog): + test_device.modem.add(test_device_2) + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + with caplog.at_level(logging.DEBUG): + two_way = True + refresh = True + test_device._db_update(test_entry_2.group, + test_entry_2.is_controller, + test_entry_2.addr, + test_entry_2.group, + two_way, + refresh, + None, + bytes([0x00, 0x00, 0x00]), + test_entry_2.data) + assert mocked.call_count == 3 + calls = [call(test_device.refresh), + call(test_device.db.add_on_device, test_entry_2.addr, + test_entry_2.group, test_entry_2.is_controller, + bytes([0x00, 0x00, 0x00])), + call(test_device_2.db_add_resp_of, test_entry_2.group, + test_device.addr, test_entry_2.group, False, + refresh, local_data=test_entry_2.data)] + mocked.assert_has_calls(calls) + + def test_db_update_resp(self, test_device, test_entry_2, + test_device_2, caplog): + test_device.modem.add(test_device_2) + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + with caplog.at_level(logging.DEBUG): + two_way = True + refresh = True + test_device._db_update(test_entry_2.group, + False, + test_entry_2.addr, + test_entry_2.group, + two_way, + refresh, + None, + bytes([0x00, 0x00, 0x00]), + test_entry_2.data) + assert mocked.call_count == 3 + calls = [call(test_device.refresh), + call(test_device.db.add_on_device, test_entry_2.addr, + test_entry_2.group, False, + bytes([0x00, 0x00, 0x00])), + call(test_device_2.db_add_ctrl_of, test_entry_2.group, + test_device.addr, test_entry_2.group, False, + refresh, local_data=test_entry_2.data)] + mocked.assert_has_calls(calls) + + def test_db_update_no_remote(self, test_device, test_entry_2, caplog): + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + with caplog.at_level(logging.DEBUG): + two_way = True + refresh = True + test_device._db_update(test_entry_2.group, + test_entry_2.is_controller, + test_entry_2.addr, + test_entry_2.group, + two_way, + refresh, + None, + bytes([0x00, 0x00, 0x00]), + test_entry_2.data) + calls = [call(test_device.refresh), + call(test_device.db.add_on_device, test_entry_2.addr, + test_entry_2.group, test_entry_2.is_controller, + bytes([0x00, 0x00, 0x00]))] + assert mocked.call_count == 2 + mocked.assert_has_calls(calls) + assert "Device db add CTRL can't find remote" in caplog.text + + def test_db_delete_no_remote_no_entry(self, test_device, test_entry_2, + caplog): + def on_done(success, msg, data): + assert not success + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + with caplog.at_level(logging.DEBUG): + two_way = True + refresh = True + test_device._db_delete(test_entry_2.addr, + test_entry_2.group, + test_entry_2.is_controller, + two_way, + refresh, + on_done) + assert mocked.call_count == 0 + assert "delete no match for 56.78.cd grp 1 CTRL" in caplog.text + + def test_db_delete(self, test_device, test_entry_2, test_device_2): + test_device.db.add_entry(test_entry_2) + test_device.modem.add(test_device_2) + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + two_way = True + refresh = True + test_device._db_delete(test_entry_2.addr, + test_entry_2.group, + test_entry_2.is_controller, + two_way, + refresh, + None) + assert mocked.call_count == 3 + calls = [call(test_device.refresh), + call(test_device.db.delete_on_device, test_entry_2), + call(test_device_2.db_del_resp_of, test_device.addr, + test_entry_2.group, two_way=False)] + mocked.assert_has_calls(calls, any_order=False) + + def test_db_delete_resp(self, test_device, test_entry_3, test_device_2): + test_device.db.add_entry(test_entry_3) + test_device.modem.add(test_device_2) + with mock.patch.object(IM.CommandSeq, 'add') as mocked: + two_way = True + refresh = True + test_device._db_delete(test_entry_3.addr, + test_entry_3.group, + test_entry_3.is_controller, + two_way, + refresh, + None) + assert mocked.call_count == 3 + calls = [call(test_device.refresh), + call(test_device.db.delete_on_device, test_entry_3), + call(test_device_2.db_del_ctrl_of, test_device.addr, + test_entry_3.group, two_way=False)] + mocked.assert_has_calls(calls, any_order=False) + + def test_load_db_exception(self, test_device, caplog): + Path(test_device.db_path()).touch() + test_device.load_db() + assert 'Error reading file' in caplog.text diff --git a/tests/device/test_BaseDev.py b/tests/device/test_BaseDev.py deleted file mode 100644 index 05de080e..00000000 --- a/tests/device/test_BaseDev.py +++ /dev/null @@ -1,73 +0,0 @@ -#=========================================================================== -# -# Tests for: insteont_mqtt/device/Base.py -# -#=========================================================================== -import pytest -# from pprint import pprint -from unittest import mock -from unittest.mock import call -import insteon_mqtt as IM -import insteon_mqtt.device.Base as Base -import insteon_mqtt.message as Msg -import insteon_mqtt.util as util -import helpers as H - -@pytest.fixture -def test_device(tmpdir): - ''' - Returns a generically configured iolinc for testing - ''' - protocol = H.main.MockProtocol() - modem = H.main.MockModem(tmpdir) - addr = IM.Address(0x01, 0x02, 0x03) - device = Base(protocol, modem, addr) - return device - - -class Test_Base_Config(): - def test_type(self, test_device): - assert test_device.type() == "base" - - def test_no_name(self, test_device): - protocol = test_device.protocol - modem = test_device.modem - #address is intentionall badly formatted - device = Base.from_config(["3 2.34:56"], protocol, modem) - assert device - - def test_with_name(self, test_device): - protocol = test_device.protocol - modem = test_device.modem - #address is intentionall badly formatted - device = Base.from_config([{"32 34 56": 'test'}], protocol, modem) - assert device - - def test_info_entry(self, test_device): - assert test_device.info_entry() == {'01.02.03': - {'label': None, - 'type': 'base'} - } - - def test_print_db(self, test_device): - # This just prints an output just make sure we don't crash - test_device.print_db(util.make_callback(None)) - assert True - - def test_pair(self, test_device): - with mock.patch.object(IM.CommandSeq, 'add'): - test_device.pair() - calls = [ - call(test_device.refresh), - ] - IM.CommandSeq.add.assert_has_calls(calls) - assert IM.CommandSeq.add.call_count == 1 - - def test_broadcast(self, test_device, caplog): - # test broadcast Messages, Base doesn't handle any - flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) - group = IM.Address(0x00, 0x00, 0x01) - addr = IM.Address(0x01, 0x02, 0x03) - msg = Msg.InpStandard(addr, group, flags, 0x11, 0x00) - test_device.handle_broadcast(msg) - assert "has no handler for broadcast" in caplog.text diff --git a/tests/device/test_BatterySensorDev.py b/tests/device/test_BatterySensorDev.py index 2efcb041..ba5a2360 100644 --- a/tests/device/test_BatterySensorDev.py +++ b/tests/device/test_BatterySensorDev.py @@ -43,26 +43,26 @@ def test_pair(self, test_device): assert mocked.call_count == 4 @pytest.mark.parametrize("cmd1,expected", [ - (Msg.CmdType.ON, True), - (Msg.CmdType.OFF, False), - (Msg.CmdType.LINK_CLEANUP_REPORT, None), + (Msg.CmdType.ON, {"is_on":True, "level":None, "group":1, "reason":'device', + "mode":IM.on_off.Mode.NORMAL}), + (Msg.CmdType.OFF, {"is_on":False, "level":None, "group":1, "reason":'device', + "mode":IM.on_off.Mode.NORMAL}), ]) def test_broadcast_1(self, test_device, cmd1, expected): - with mock.patch.object(Device.BatterySensor, '_set_is_on') as mocked: + with mock.patch.object(Device.BatterySensor, '_set_state') as mocked: flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) group = IM.Address(0x00, 0x00, 0x01) addr = IM.Address(0x01, 0x02, 0x03) msg = Msg.InpStandard(addr, group, flags, cmd1, 0x00) test_device.handle_broadcast(msg) if expected is not None: - mocked.assert_called_once_with(expected) + mocked.assert_called_once_with(**expected) else: mocked.assert_not_called() @pytest.mark.parametrize("cmd1,expected", [ (Msg.CmdType.ON, True), (Msg.CmdType.OFF, False), - (Msg.CmdType.LINK_CLEANUP_REPORT, None), ]) def test_broadcast_3(self, test_device, cmd1, expected): with mock.patch.object(IM.Signal, 'emit') as mocked: @@ -79,7 +79,6 @@ def test_broadcast_3(self, test_device, cmd1, expected): @pytest.mark.parametrize("cmd1,expected", [ (Msg.CmdType.ON, True), (Msg.CmdType.OFF, True), - (Msg.CmdType.LINK_CLEANUP_REPORT, None), ]) def test_broadcast_4(self, test_device, cmd1, expected): with mock.patch.object(IM.Signal, 'emit') as mocked: diff --git a/tests/device/test_DimmerDev.py b/tests/device/test_DimmerDev.py index 710d3cd9..21e351c3 100644 --- a/tests/device/test_DimmerDev.py +++ b/tests/device/test_DimmerDev.py @@ -38,10 +38,10 @@ def test_pair(self, test_device): assert IM.CommandSeq.add.call_count == 2 @pytest.mark.parametrize("group,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (0x01,Msg.CmdType.OFF, 0x00, {"level":0,"mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (0x01,Msg.CmdType.ON_FAST, 0x00,{"level":255,"mode":IM.on_off.Mode.FAST, "reason":'device'}), - (0x01,Msg.CmdType.OFF_FAST, 0x00, {"level":0,"mode":IM.on_off.Mode.FAST, "reason":'device'}), + (0x01,Msg.CmdType.ON, 0x00,{"level":255,"is_on":True,"mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.OFF, 0x00, {"level":0,"is_on":False,"mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.ON_FAST, 0x00,{"level":255,"is_on":True,"mode":IM.on_off.Mode.FAST, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.OFF_FAST, 0x00, {"level":0,"is_on":False,"mode":IM.on_off.Mode.FAST, "button":1, "reason":'device'}), (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), ]) def test_handle_on_off(self, test_device, group, cmd1, cmd2, expected): @@ -57,9 +57,9 @@ def test_handle_on_off(self, test_device, group, cmd1, cmd2, expected): mocked.assert_not_called() @pytest.mark.parametrize("group,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.DOWN, "reason":'device'}), - (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x01, {"manual":IM.on_off.Manual.UP, "reason":'device'}), - (0x01,Msg.CmdType.STOP_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.STOP, "reason":'device'}), + (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.DOWN, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x01, {"manual":IM.on_off.Manual.UP, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.STOP_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.STOP, "button":1, "reason":'device'}), ]) def test_handle_on_off_manual(self, test_device, group, cmd1, cmd2, expected): with mock.patch.object(IM.Signal, 'emit') as mocked: @@ -84,14 +84,14 @@ def level_bytes(level): return data assert(test_device.get_on_level() == 255) for params in ([1, 0x01], [127, 127], [255, 0xFF]): - test_device.set_on_level(params[0]) + test_device.set_on_level(on_level=params[0]) assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x2e assert (test_device.protocol.sent[0].msg.data == level_bytes(params[1])) test_device.protocol.clear() - test_device.set_on_level(64) + test_device.set_on_level(on_level=64) # Fake having completed the set_on_level(64) request flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) @@ -106,19 +106,23 @@ def level_bytes(level): # default on-level then to full brightness, as expected. # Fast-on should always go to full brightness. params = [ - (Msg.CmdType.ON, 0x00, {"level":64, "mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (Msg.CmdType.ON, 0x00, {"level":255, "mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (Msg.CmdType.ON, 0x00, {"level":64, "mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (Msg.CmdType.OFF, 0x00, {"level":0, "mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (Msg.CmdType.ON_FAST, 0x00, {"level":255, "mode":IM.on_off.Mode.FAST, "reason":'device'}), - (Msg.CmdType.ON_FAST, 0x00, {"level":255, "mode":IM.on_off.Mode.FAST, "reason":'device'}), - (Msg.CmdType.OFF_FAST, 0x00, {"level":0, "mode":IM.on_off.Mode.FAST, "reason":'device'}), + (Msg.CmdType.ON, 0x00, {"level":64, "is_on":True, "mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (Msg.CmdType.ON, 0x00, {"level":255, "is_on":True, "mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (Msg.CmdType.ON, 0x00, {"level":64, "is_on":True, "mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (Msg.CmdType.OFF, 0x00, {"level":0, "is_on":False, "mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (Msg.CmdType.ON_FAST, 0x00, {"level":255, "is_on":True, + "mode":IM.on_off.Mode.FAST, + "button":1, "reason":'device'}), + (Msg.CmdType.ON_FAST, 0x00, {"level":255, "is_on":True, + "mode":IM.on_off.Mode.FAST, + "button":1, "reason":'device'}), + (Msg.CmdType.OFF_FAST, 0x00, {"level":0, "is_on":False, "mode":IM.on_off.Mode.FAST, "button":1, "reason":'device'}), (Msg.CmdType.ON_INSTANT, 0x00, - {"level":64, "mode":IM.on_off.Mode.INSTANT, "reason":'device'}), + {"level":64, "is_on":True, "mode":IM.on_off.Mode.INSTANT, "button":1, "reason":'device'}), (Msg.CmdType.ON_INSTANT, 0x00, - {"level":255,"mode": IM.on_off.Mode.INSTANT, "reason":'device'}), + {"level":255, "is_on":True, "mode": IM.on_off.Mode.INSTANT, "button":1, "reason":'device'}), (Msg.CmdType.ON_INSTANT, 0x00, - {"level":64, "mode":IM.on_off.Mode.INSTANT, "reason":'device'})] + {"level":64, "is_on":True, "mode":IM.on_off.Mode.INSTANT, "button":1, "reason":'device'})] for cmd1, cmd2, expected in params: with mock.patch.object(IM.Signal, 'emit') as mocked: print("Trying:", "[%x, %x]" % (cmd1, cmd2)) @@ -159,7 +163,7 @@ def on_done(success, *args): def test_set_backlight(self, test_device): # set_backlight(self, level, on_done=None) - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x20 assert test_device.protocol.sent[0].msg.cmd2 == 0x08 @@ -175,7 +179,7 @@ def level_bytes(level): for params in ([1, 0x01], [255, 0xFF], [127, 127]): with mock.patch.object(IM.CommandSeq, 'add_msg'): - test_device.set_backlight(params[0]) + test_device.set_backlight(backlight=params[0]) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 2 # Check the first call @@ -188,7 +192,7 @@ def level_bytes(level): with mock.patch.object(IM.CommandSeq, 'add_msg'): # test backlight off - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 1 # Check the first call diff --git a/tests/device/test_IOLincDev.py b/tests/device/test_IOLincDev.py index ca778512..0d874162 100644 --- a/tests/device/test_IOLincDev.py +++ b/tests/device/test_IOLincDev.py @@ -12,8 +12,9 @@ from unittest import mock from unittest.mock import call import insteon_mqtt as IM -import insteon_mqtt.device.IOLinc as IOLinc +from insteon_mqtt.device.IOLinc import IOLinc import insteon_mqtt.message as Msg +import helpers as H @pytest.fixture @@ -21,8 +22,8 @@ def test_iolinc(tmpdir): ''' Returns a generically configured iolinc for testing ''' - protocol = MockProto() - modem = MockModem(tmpdir) + protocol = H.main.MockProtocol() + modem = H.main.MockModem(tmpdir) addr = IM.Address(0x01, 0x02, 0x03) iolinc = IOLinc(protocol, modem, addr) return iolinc @@ -74,14 +75,13 @@ def test_set_flags_unknown(self, test_iolinc, caplog): with mock.patch.object(IM.CommandSeq, 'add_msg'): test_iolinc.set_flags(None, Unknown=1) assert IM.CommandSeq.add_msg.call_count == 0 - assert 'Unknown IOLinc flags input' in caplog.text + assert 'Unknown set flags input' in caplog.text @pytest.mark.parametrize("mode,expected", [ ("latching", [0x07, 0x13, 0x15]), ("momentary_a", [0x06, 0x13, 0x15]), ("momentary_b", [0x06, 0x12, 0x15]), ("momentary_c", [0x06, 0x12, 0x14]), - ("bad-mode", [0x07, 0x13, 0x15]), ]) def test_set_flags_mode(self, test_iolinc, mode, expected): self.mode = IM.device.IOLinc.Modes.LATCHING @@ -107,17 +107,13 @@ class BadModes(enum.IntEnum): assert "Bad value BadModes.BAD, for mode on IOLinc" in caplog.text @pytest.mark.parametrize("flag,expected", [ - ({"trigger_reverse": 0}, [0x20, 0x0f]), - ({"trigger_reverse": 1}, [0x20, 0x0e]), - ({"relay_linked": 0}, [0x20, 0x05]), - ({"relay_linked": 1}, [0x20, 0x04]), ({"momentary_secs": .1}, [0x2e, 0x00, 0x01, 0x01]), ({"momentary_secs": 26}, [0x2e, 0x00, 0x1a, 0x0a]), ({"momentary_secs": 260}, [0x2e, 0x00, 0x1a, 0x64]), ({"momentary_secs": 3000}, [0x2e, 0x00, 0x96, 0xc8]), ({"momentary_secs": 6300}, [0x2e, 0x00, 0xfc, 0xfa]), ]) - def test_set_flags_other(self, test_iolinc, flag, expected): + def test_set_flags_momentary(self, test_iolinc, flag, expected): test_iolinc.momentary_secs = 0 test_iolinc.relay_linked = 0 test_iolinc.trigger_reverse = 0 @@ -125,6 +121,7 @@ def test_set_flags_other(self, test_iolinc, flag, expected): test_iolinc.set_flags(None, **flag) # Check that the first call is for standard flags # Call#, Args, First Arg + assert IM.CommandSeq.add_msg.call_count == 2 calls = IM.CommandSeq.add_msg.call_args_list assert calls[0][0][0].cmd1 == expected[0] assert calls[0][0][0].cmd2 == expected[1] @@ -137,6 +134,23 @@ def test_set_flags_other(self, test_iolinc, flag, expected): else: assert IM.CommandSeq.add_msg.call_count == 1 + @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]), + ]) + 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 + test_iolinc.set_flags(None, **flag) + # Check that the first call is for standard flags + # Call#, Args, First Arg + assert len(test_iolinc.protocol.sent) == 1 + assert test_iolinc.protocol.sent[0].msg.cmd1 == expected[0] + assert test_iolinc.protocol.sent[0].msg.cmd2 == expected[1] + class Test_IOLinc_Set(): @pytest.mark.parametrize("level,expected", [ @@ -145,11 +159,11 @@ class Test_IOLinc_Set(): (0xff, 0x11), ]) def test_set(self, test_iolinc, level, expected): - with mock.patch.object(IM.device.Base, 'send'): + with mock.patch.object(IM.device.base.Base, 'send'): test_iolinc.set(level) - calls = IM.device.Base.send.call_args_list + calls = IM.device.base.Base.send.call_args_list assert calls[0][0][0].cmd1 == expected - assert IM.device.Base.send.call_count == 1 + assert IM.device.base.Base.send.call_count == 1 @pytest.mark.parametrize("is_on,expected", [ (True, True), @@ -157,30 +171,31 @@ def test_set(self, test_iolinc, level, expected): ]) 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) + test_iolinc._set_state(group=1, is_on=is_on) calls = IM.Signal.emit.call_args_list - assert calls[0][0][1] == expected + assert calls[0][1]['is_on'] == expected + assert calls[0][1]['button'] == 1 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), + @pytest.mark.parametrize("is_on, mode, relay, add, remove", [ + (True, IM.device.IOLinc.Modes.LATCHING, True, 0, 0), + (True, IM.device.IOLinc.Modes.MOMENTARY_A, True, 1, 0), + (True, IM.device.IOLinc.Modes.MOMENTARY_A, True, 1, 1), + (False, IM.device.IOLinc.Modes.MOMENTARY_A, False, 0, 0), + (False, IM.device.IOLinc.Modes.MOMENTARY_A, False, 0, 0), + (False, IM.device.IOLinc.Modes.MOMENTARY_A, False, 0, 1), ]) - def test_relay_on(self, test_iolinc, is_on, mode, moment, relay, - add, remove): + def test_relay_on(self, test_iolinc, is_on, mode, relay, add, remove): with mock.patch.object(IM.Signal, 'emit'): with mock.patch.object(test_iolinc.modem.timed_call, 'add'): with mock.patch.object(test_iolinc.modem.timed_call, 'remove'): test_iolinc.mode = mode if remove > 0: test_iolinc._momentary_call = True - test_iolinc._set_relay_is_on(is_on, momentary=moment) + test_iolinc._set_state(group=2, is_on=is_on) emit_calls = IM.Signal.emit.call_args_list - assert emit_calls[0][0][2] == relay + assert emit_calls[0][1]['is_on'] == relay + assert emit_calls[0][1]['button'] == 2 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 @@ -206,10 +221,14 @@ def test_handle_broadcast(self, test_iolinc, linked, cmd1, sensor, test_iolinc.handle_broadcast(msg) calls = IM.Signal.emit.call_args_list if linked: - assert calls[1][0][2] == relay + assert calls[0][1]['is_on'] == relay + assert calls[0][1]['button'] == 2 + assert calls[1][1]['is_on'] == sensor + assert calls[1][1]['button'] == 1 assert IM.Signal.emit.call_count == 2 elif sensor is not None: - assert calls[0][0][1] == sensor + assert calls[0][1]['is_on'] == sensor + assert calls[0][1]['button'] == 1 assert IM.Signal.emit.call_count == 1 else: assert IM.Signal.emit.call_count == 0 @@ -265,15 +284,6 @@ def test_handle_momentary(self, test_iolinc, time_val, multiplier, 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), @@ -284,9 +294,10 @@ def test_handle_refresh_relay(self, test_iolinc, cmd2, expected): 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) + test_iolinc.handle_refresh(msg, group=2) calls = IM.Signal.emit.call_args_list - assert calls[0][0][2] == expected + assert calls[0][1]['is_on'] == expected + assert calls[0][1]['button'] == 2 assert IM.Signal.emit.call_count == 1 @pytest.mark.parametrize("cmd2,expected", [ @@ -299,9 +310,10 @@ def test_handle_refresh_sensor(self, test_iolinc, cmd2, expected): 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) + test_iolinc.handle_refresh(msg, group=1) calls = IM.Signal.emit.call_args_list - assert calls[0][0][1] == expected + assert calls[0][1]['is_on'] == expected + assert calls[0][1]['button'] == 1 assert IM.Signal.emit.call_count == 1 @pytest.mark.parametrize("cmd1, type, expected", [ @@ -316,7 +328,8 @@ def test_handle_ack(self, test_iolinc, cmd1, type, expected): 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 - assert calls[0][0][2] == expected + assert calls[0][1]['is_on'] == expected + assert calls[0][1]['button'] == 2 assert IM.Signal.emit.call_count == 1 @pytest.mark.parametrize("cmd1, entry_d1, mode, sensor, expected", [ @@ -363,7 +376,9 @@ def test_handle_group_cmd(self, test_iolinc, cmd1, entry_d1, mode, # Test the responses received calls = IM.Signal.emit.call_args_list if expected is not None: - assert calls[0][0][2] == expected + print(calls) + assert calls[0][1]['is_on'] == expected + assert calls[0][1]['button'] == 2 assert IM.Signal.emit.call_count == 1 else: assert IM.Signal.emit.call_count == 0 @@ -385,32 +400,3 @@ def test_link_data(self, test_iolinc, data_1, pretty_data_1, name, '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/device/test_KeypadLincDev.py b/tests/device/test_KeypadLincDev.py index b04a1060..b0914548 100644 --- a/tests/device/test_KeypadLincDev.py +++ b/tests/device/test_KeypadLincDev.py @@ -20,7 +20,7 @@ def test_device(tmpdir): protocol = H.main.MockProtocol() modem = H.main.MockModem(tmpdir) addr = IM.Address(0x01, 0x02, 0x03) - device = IM.device.KeypadLinc(protocol, modem, addr, 'test_device') + device = IM.device.KeypadLincDimmer(protocol, modem, addr, 'test_device') return device class Test_KPL(): @@ -56,10 +56,10 @@ def test_refresh(self, test_device): assert IM.CommandSeq.add_msg.call_count == 3 # Check the first call assert args_list[0][0][0].cmd1 == 0x19 - assert args_list[0][0][0].cmd2 == 0x01 + assert args_list[0][0][0].cmd2 == 0x00 # Check the second call assert args_list[1][0][0].cmd1 == 0x19 - assert args_list[1][0][0].cmd2 == 0x00 + assert args_list[1][0][0].cmd2 == 0x01 # Check the third call assert args_list[2][0][0].cmd1 == 0x2e assert args_list[2][0][0].cmd2 == 0x00 @@ -106,26 +106,12 @@ def test_link_data_from_pretty(self, test_device): def test_increment_up(self, test_device): # increment_up(self, reason="", on_done=None) - # Switch shouldn't do anything - test_device.is_dimmer = False - test_device.increment_up() - assert len(test_device.protocol.sent) == 0 - - # dimmer - test_device.is_dimmer = True test_device.increment_up() assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x15 def test_increment_down(self, test_device): # increment_up(self, reason="", on_done=None) - # Switch shouldn't do anything - test_device.is_dimmer = False - test_device.increment_down() - assert len(test_device.protocol.sent) == 0 - - # dimmer - test_device.is_dimmer = True test_device.increment_down() assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x16 @@ -178,7 +164,7 @@ def group_bytes(group): def test_set_backlight(self, test_device): # set_backlight(self, level, on_done=None) - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x20 assert test_device.protocol.sent[0].msg.cmd2 == 0x08 @@ -194,7 +180,7 @@ def level_bytes(level): for params in ([1, 0x01], [255, 0xFF], [127, 127]): with mock.patch.object(IM.CommandSeq, 'add_msg'): - test_device.set_backlight(params[0]) + test_device.set_backlight(backlight=params[0]) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 2 # Check the first call @@ -207,7 +193,7 @@ def level_bytes(level): with mock.patch.object(IM.CommandSeq, 'add_msg'): # test backlight off - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 1 # Check the first call @@ -216,13 +202,6 @@ def level_bytes(level): def test_set_ramp_rate(self, test_device): # set_ramp_rate(self, rate, on_done=None) - # Test switch - test_device.is_dimmer = False - test_device.set_ramp_rate(5) - assert len(test_device.protocol.sent) == 0 - - # Test dimmer - test_device.is_dimmer = True def level_bytes(level): data = bytes([ 0x01, # D1 must be group 0x01 @@ -231,7 +210,7 @@ def level_bytes(level): ] + [0x00] * 11) return data for params in ([.1, 0x1f], [540, 0x00], [600, 0x00], [.0001, 0x1c]): - test_device.set_ramp_rate(params[0]) + test_device.set_ramp_rate(ramp_rate=params[0]) assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x2e assert test_device.protocol.sent[0].msg.data == level_bytes(params[1]) @@ -239,13 +218,6 @@ def level_bytes(level): def test_set_on_level(self, test_device): # set_on_level(self, level, on_done=None) - # Test switch - test_device.is_dimmer = False - test_device.set_on_level(5) - assert len(test_device.protocol.sent) == 0 - - # Test dimmer - test_device.is_dimmer = True assert(test_device.get_on_level() == 255) def level_bytes(level): @@ -256,14 +228,14 @@ def level_bytes(level): ] + [0x00] * 11) return data for params in ([1, 0x01], [127, 127], [255, 0xFF]): - test_device.set_on_level(params[0]) + test_device.set_on_level(on_level=params[0]) assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x2e assert (test_device.protocol.sent[0].msg.data == level_bytes(params[1])) test_device.protocol.clear() - test_device.set_on_level(64) + test_device.set_on_level(on_level=64) # Fake having completed the set_on_level(64) request flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) @@ -278,19 +250,19 @@ def level_bytes(level): # default on-level then to full brightness, as expected. # Fast-on should always go to full brightness. params = [ - (Msg.CmdType.ON, 0x00, {"level":64, "mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":1}), - (Msg.CmdType.ON, 0x00, {"level":255, "mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":1}), - (Msg.CmdType.ON, 0x00, {"level":64, "mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":1}), - (Msg.CmdType.OFF, 0x00, {"level":0, "mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":1}), - (Msg.CmdType.ON_FAST, 0x00, {"level":255, "mode":IM.on_off.Mode.FAST, "reason":'device', "button":1}), - (Msg.CmdType.ON_FAST, 0x00, {"level":255, "mode":IM.on_off.Mode.FAST, "reason":'device', "button":1}), - (Msg.CmdType.OFF_FAST, 0x00, {"level":0, "mode":IM.on_off.Mode.FAST, "reason":'device', "button":1}), + (Msg.CmdType.ON, 0x00, {"level":64, "mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":1}), + (Msg.CmdType.ON, 0x00, {"level":255, "mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":1}), + (Msg.CmdType.ON, 0x00, {"level":64, "mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":1}), + (Msg.CmdType.OFF, 0x00, {"level":0, "mode":IM.on_off.Mode.NORMAL, "is_on": False, "reason":'device', "button":1}), + (Msg.CmdType.ON_FAST, 0x00, {"level":255, "mode":IM.on_off.Mode.FAST, "is_on": True, "reason":'device', "button":1}), + (Msg.CmdType.ON_FAST, 0x00, {"level":255, "mode":IM.on_off.Mode.FAST, "is_on": True, "reason":'device', "button":1}), + (Msg.CmdType.OFF_FAST, 0x00, {"level":0, "mode":IM.on_off.Mode.FAST, "is_on": False, "reason":'device', "button":1}), (Msg.CmdType.ON_INSTANT, 0x00, - {"level":64, "mode":IM.on_off.Mode.INSTANT, "reason":'device', "button":1}), + {"level":64, "mode":IM.on_off.Mode.INSTANT, "is_on": True, "reason":'device', "button":1}), (Msg.CmdType.ON_INSTANT, 0x00, - {"level":255, "mode":IM.on_off.Mode.INSTANT, "reason":'device', "button":1}), + {"level":255, "mode":IM.on_off.Mode.INSTANT, "is_on": True, "reason":'device', "button":1}), (Msg.CmdType.ON_INSTANT, 0x00, - {"level":64, "mode":IM.on_off.Mode.INSTANT, "reason":'device', "button":1})] + {"level":64, "mode":IM.on_off.Mode.INSTANT, "is_on": True, "reason":'device', "button":1})] for cmd1, cmd2, expected in params: with mock.patch.object(IM.Signal, 'emit') as mocked: print("Trying:", "[%x, %x]" % (cmd1, cmd2)) @@ -323,7 +295,7 @@ def test_set_flags(self, test_device): args_list = IM.CommandSeq.add.call_args_list assert IM.CommandSeq.add.call_count == 1 assert args_list[0][0][0] == params[1] - assert args_list[0][0][1] == params[2] + assert args_list[0][1] == params[0] def test_handle_refresh_state(self, test_device): # handle_refresh_state(self, msg, on_done): @@ -336,23 +308,24 @@ def on_done(success, *args): @pytest.mark.parametrize("group_num,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":1}), - (0x01,Msg.CmdType.OFF, 0x00, {"level":0,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":1}), - (0x01,Msg.CmdType.ON_FAST, 0x00,{"level":255,"mode":IM.on_off.Mode.FAST, "reason":'device', "button":1}), - (0x01,Msg.CmdType.OFF_FAST, 0x00, {"level":0,"mode":IM.on_off.Mode.FAST, "reason":'device', "button":1}), - (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.DOWN, "reason":'device', "button":1}), - (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x01, {"manual":IM.on_off.Manual.UP, "reason":'device', "button":1}), - (0x01,Msg.CmdType.STOP_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.STOP, "reason":'device', "button":1}), + (0x01,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":1}), + (0x01,Msg.CmdType.OFF, 0x00, {"level":0,"mode":IM.on_off.Mode.NORMAL, "is_on": False, "reason":'device', "button":1}), + (0x01,Msg.CmdType.ON_FAST, 0x00,{"level":255,"mode":IM.on_off.Mode.FAST, "is_on": True, "reason":'device', "button":1}), + (0x01,Msg.CmdType.OFF_FAST, 0x00, {"level":0,"mode":IM.on_off.Mode.FAST, "is_on": False, "reason":'device', "button":1}), + (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.DOWN, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x01, {"manual":IM.on_off.Manual.UP, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.STOP_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.STOP, "button":1, "reason":'device'}), (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), - (0x02,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":2}), - (0x03,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":3}), - (0x04,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":4}), - (0x05,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":5}), - (0x06,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":6}), - (0x07,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":7}), - (0x08,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "reason":'device', "button":8}), + (0x02,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":2}), + (0x03,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":3}), + (0x04,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":4}), + (0x05,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":5}), + (0x06,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":6}), + (0x07,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":7}), + (0x08,Msg.CmdType.ON, 0x00,{"level":255,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":8}), ]) def test_handle_on_off(self, test_device, group_num, cmd1, cmd2, expected): + test_device._load_group = 1 with mock.patch.object(IM.Signal, 'emit') as mocked: flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) group = IM.Address(0x00, 0x00, group_num) diff --git a/tests/device/test_KeypadLincDev_sw.py b/tests/device/test_KeypadLincDev_sw.py new file mode 100644 index 00000000..be409461 --- /dev/null +++ b/tests/device/test_KeypadLincDev_sw.py @@ -0,0 +1,298 @@ +#=========================================================================== +# +# Tests for: insteont_mqtt/device/KeypadLinc.py +# +#=========================================================================== +import pytest + +from unittest import mock +from unittest.mock import call +import insteon_mqtt as IM +import insteon_mqtt.message as Msg +import insteon_mqtt.util as util +import helpers as H + +@pytest.fixture +def test_device(tmpdir): + ''' + Returns a generically configured keypadlinc for testing + ''' + protocol = H.main.MockProtocol() + modem = H.main.MockModem(tmpdir) + addr = IM.Address(0x01, 0x02, 0x03) + device = IM.device.KeypadLinc(protocol, modem, addr, 'test_device') + return device + +class Test_KPL(): + def test_pair(self, test_device): + with mock.patch.object(IM.CommandSeq, 'add'): + test_device.pair() + calls = [ + call(test_device.refresh), + call(test_device.db_add_ctrl_of, 0x01, test_device.modem.addr, 0x01, + refresh=False), + call(test_device.db_add_ctrl_of, 0x02, test_device.modem.addr, 0x01, + refresh=False), + call(test_device.db_add_ctrl_of, 0x03, test_device.modem.addr, 0x01, + refresh=False), + call(test_device.db_add_ctrl_of, 0x04, test_device.modem.addr, 0x01, + refresh=False), + call(test_device.db_add_ctrl_of, 0x05, test_device.modem.addr, 0x01, + refresh=False), + call(test_device.db_add_ctrl_of, 0x06, test_device.modem.addr, 0x01, + refresh=False), + call(test_device.db_add_ctrl_of, 0x07, test_device.modem.addr, 0x01, + refresh=False), + call(test_device.db_add_ctrl_of, 0x08, test_device.modem.addr, 0x01, + refresh=False) + ] + IM.CommandSeq.add.assert_has_calls(calls) + assert IM.CommandSeq.add.call_count == 9 + + def test_refresh(self, test_device): + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_device.refresh() + args_list = IM.CommandSeq.add_msg.call_args_list + assert IM.CommandSeq.add_msg.call_count == 3 + # Check the first call + assert args_list[0][0][0].cmd1 == 0x19 + assert args_list[0][0][0].cmd2 == 0x00 + # Check the second call + assert args_list[1][0][0].cmd1 == 0x19 + assert args_list[1][0][0].cmd2 == 0x01 + # Check the third call + assert args_list[2][0][0].cmd1 == 0x2e + assert args_list[2][0][0].cmd2 == 0x00 + assert isinstance(args_list[2][0][0], Msg.OutExtended) + + def test_link_data(self, test_device): + # is_controller, group, data=None + data = test_device.link_data(True, 0x01, data=None) + assert data == bytes([0x03, 0x00, 0x01]) + data = test_device.link_data(True, 0x02, data=None) + assert data == bytes([0x03, 0x00, 0x02]) + data = test_device.link_data(False, 0x02, data=None) + assert data == bytes([0xff, 0x00, 0x02]) + + def test_link_data_pretty(self, test_device): + # is_controller, data + data = test_device.link_data_to_pretty(True, data=[0x00, 0x00, 0x00]) + assert data == [{'data_1': 0}, {'data_2': 0}, {'group': 0}] + data = test_device.link_data_to_pretty(True, data=[0x01, 0x00, 0x00]) + assert data == [{'data_1': 1}, {'data_2': 0}, {'group': 0}] + data = test_device.link_data_to_pretty(False, data=[0xff, 0x00, 0x00]) + assert data == [{'data_1': 255}, {'data_2': 0}, {'group': 0}] + data = test_device.link_data_to_pretty(False, data=[0xff, 0x1f, 0x05]) + assert data == [{'data_1': 255}, {'data_2': 0x1F}, {'group': 5}] + + def test_link_data_from_pretty(self, test_device): + # link_data_from_pretty(self, is_controller, data): + data = test_device.link_data_from_pretty(False, data={'group': 5}) + assert data == [None, None, 0x05] + data = test_device.link_data_from_pretty(False, data={'group': 1}) + assert data == [None, None, 0x01] + data = test_device.link_data_from_pretty(True, data={'data_1': 0x01, + 'data_2': 0x02, + 'data_3': 0x03}) + assert data == [0x01, 0x02, 0x03] + + def test_set_load_attached(self, test_device): + # set_load_attached(self, is_attached, on_done=None): + test_device.set_load_attached(True) + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == 0x20 + assert test_device.protocol.sent[0].msg.cmd2 == 0x1a + + test_device.protocol.clear() + test_device.set_load_attached(False) + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == 0x20 + assert test_device.protocol.sent[0].msg.cmd2 == 0x1b + + def test_set_button_led(self, test_device): + # set_button_led(self, group, is_on, reason="", on_done=None) + group = 0x09 + test_device.set_button_led(group, True) + assert len(test_device.protocol.sent) == 0 + + group = 0x00 + test_device.set_button_led(group, True) + assert len(test_device.protocol.sent) == 0 + + group = 0x01 + test_device.set_button_led(group, True) + assert len(test_device.protocol.sent) == 0 + + test_device._load_group = 1 + group = 0x01 + test_device.set_button_led(group, True) + assert len(test_device.protocol.sent) == 0 + + def group_bytes(group): + data = bytes([ + 0x01, + 0x09, + group, + ] + [0x00] * 11) + return data + for params in ([2, True, 0x02], [5, True, 0x10], [2, False, 0x00]): + test_device.set_button_led(params[0], params[1]) + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == 0x2e + assert test_device.protocol.sent[0].msg.data == group_bytes(params[2]) + test_device.protocol.clear() + + def test_set_backlight(self, test_device): + # set_backlight(self, level, on_done=None) + test_device.set_backlight(backlight=0) + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == 0x20 + assert test_device.protocol.sent[0].msg.cmd2 == 0x08 + test_device.protocol.clear() + + def level_bytes(level): + data = bytes([ + 0x01, # D1 must be group 0x01 + 0x07, # D2 set global led brightness + level, # D3 brightness level + ] + [0x00] * 11) + return data + + for params in ([1, 0x01], [255, 0xFF], [127, 127]): + with mock.patch.object(IM.CommandSeq, 'add_msg'): + test_device.set_backlight(backlight=params[0]) + args_list = IM.CommandSeq.add_msg.call_args_list + assert IM.CommandSeq.add_msg.call_count == 2 + # Check the first call + assert args_list[0][0][0].cmd1 == 0x20 + assert args_list[0][0][0].cmd2 == 0x09 + # Check the first call + assert args_list[1][0][0].cmd1 == 0x2e + assert args_list[1][0][0].data == level_bytes(params[1]) + + + with mock.patch.object(IM.CommandSeq, 'add_msg'): + # test backlight off + test_device.set_backlight(backlight=0) + args_list = IM.CommandSeq.add_msg.call_args_list + assert IM.CommandSeq.add_msg.call_count == 1 + # Check the first call + assert args_list[0][0][0].cmd1 == 0x20 + assert args_list[0][0][0].cmd2 == 0x08 + + def test_set_flags(self, test_device): + # set_flags(self, on_done, **kwargs) + for params in ([{'backlight': 1}, test_device.set_backlight, 0x01], + [{'load_attached': 1}, test_device.set_load_attached, 0x01], + [{'follow_mask': 1, "group": 4}, + test_device.set_led_follow_mask, 4], + [{'off_mask': 1, "group": 4}, + test_device.set_led_off_mask, 4], + [{'signal_bits': 1}, test_device.set_signal_bits, 0x01], + [{'nontoggle_bits': 1}, test_device.set_nontoggle_bits, 0x01], + ): + with mock.patch.object(IM.CommandSeq, 'add'): + test_device.set_flags(None, **params[0]) + args_list = IM.CommandSeq.add.call_args_list + assert IM.CommandSeq.add.call_count == 1 + assert args_list[0][0][0] == params[1] + assert args_list[0][1] == params[0] + + def test_handle_refresh_state(self, test_device): + # handle_refresh_state(self, msg, on_done): + def on_done(success, *args): + assert success == True + msg = Msg.OutStandard(IM.Address(12,14,15), + Msg.Flags(Msg.Flags.Type.DIRECT_ACK, False), + 0x2e, 0x00, is_ack=True) + test_device.handle_refresh_state(msg, on_done) + + + @pytest.mark.parametrize("group_num,cmd1,cmd2,expected", [ + (0x01,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":1}), + (0x01,Msg.CmdType.OFF, 0x00, {"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": False, "reason":'device', "button":1}), + (0x01,Msg.CmdType.ON_FAST, 0x00,{"level":None,"mode":IM.on_off.Mode.FAST, "is_on": True, "reason":'device', "button":1}), + (0x01,Msg.CmdType.OFF_FAST, 0x00, {"level":None,"mode":IM.on_off.Mode.FAST, "is_on": False, "reason":'device', "button":1}), + (0x01,Msg.CmdType.STOP_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.STOP, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), + (0x02,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":2}), + (0x03,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":3}), + (0x04,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":4}), + (0x05,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":5}), + (0x06,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":6}), + (0x07,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":7}), + (0x08,Msg.CmdType.ON, 0x00,{"level":None,"mode":IM.on_off.Mode.NORMAL, "is_on": True, "reason":'device', "button":8}), + ]) + def test_handle_on_off(self, test_device, group_num, cmd1, cmd2, expected): + with mock.patch.object(IM.Signal, 'emit') as mocked: + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, group_num) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, cmd1, cmd2) + test_device.handle_broadcast(msg) + if expected is not None: + mocked.assert_called_once_with(test_device, **expected) + else: + mocked.assert_not_called() + + def test_handle_manual_load(self, test_device): + test_device._load_group = 1 + with mock.patch.object(IM.Signal, 'emit') as mocked: + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.START_MANUAL_CHANGE, 0x00) + test_device.handle_broadcast(msg) + calls = [ + call(test_device, manual=IM.on_off.Manual.DOWN, button=1, + reason='device'), + call(test_device, button=1, level=0x00, is_on=None, + reason='device', mode=IM.on_off.Mode.MANUAL) + ] + assert mocked.call_count == 2 + mocked.assert_has_calls(calls, any_order=True) + with mock.patch.object(IM.Signal, 'emit') as mocked: + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.START_MANUAL_CHANGE, 0x01) + test_device.handle_broadcast(msg) + calls = [ + call(test_device, manual=IM.on_off.Manual.UP, button=1, + reason='device'), + call(test_device, button=1, is_on=None, level=0xFF, + reason='device', mode=IM.on_off.Mode.MANUAL) + ] + assert mocked.call_count == 2 + mocked.assert_has_calls(calls, any_order=True) + + def test_handle_manual_not_load(self, test_device): + test_device._load_group = 1 + with mock.patch.object(IM.Signal, 'emit') as mocked: + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x02) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.START_MANUAL_CHANGE, 0x00) + test_device.handle_broadcast(msg) + calls = [ + call(test_device, manual=IM.on_off.Manual.DOWN, button=2, + reason='device') + ] + assert mocked.call_count == 1 + mocked.assert_has_calls(calls, any_order=True) + with mock.patch.object(IM.Signal, 'emit') as mocked: + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x02) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.START_MANUAL_CHANGE, 0x01) + test_device.handle_broadcast(msg) + calls = [ + call(test_device, manual=IM.on_off.Manual.UP, button=2, + reason='device') + ] + assert mocked.call_count == 1 + mocked.assert_has_calls(calls, any_order=True) diff --git a/tests/device/test_LeakDev.py b/tests/device/test_LeakDev.py index 66907629..502a3f92 100644 --- a/tests/device/test_LeakDev.py +++ b/tests/device/test_LeakDev.py @@ -41,17 +41,27 @@ def test_pair(self, test_device): IM.CommandSeq.add.assert_has_calls(calls) assert IM.CommandSeq.add.call_count == 4 - @pytest.mark.parametrize("group_num,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.ON, 0x00,[False]), - (0x01,Msg.CmdType.OFF, 0x00, [False]), - (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), - (0x02,Msg.CmdType.ON, 0x00,[True]), - (0x02,Msg.CmdType.OFF, 0x00, [True]), - (0x02,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), - (0x04,Msg.CmdType.ON, 0x00,[True]), - (0x04,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), + @pytest.mark.parametrize("group_num,cmd1,cmd2,expected,kwargs", [ + (0x01,Msg.CmdType.ON, 0x00, [], {'button': 1, 'is_on': True, + 'level': None, + 'mode': IM.on_off.Mode.NORMAL, + 'reason': 'device'}), + (0x01,Msg.CmdType.OFF, 0x00, [], {'button': 1, 'is_on': False, + 'level': None, + 'mode': IM.on_off.Mode.NORMAL, + 'reason': 'device'}), + (0x02,Msg.CmdType.ON, 0x00, [], {'button': 2, 'is_on': True, + 'level': None, + 'mode': IM.on_off.Mode.NORMAL, + 'reason': 'device'}), + (0x02,Msg.CmdType.OFF, 0x00, [], {'button': 2, 'is_on': False, + 'level': None, + 'mode': IM.on_off.Mode.NORMAL, + 'reason': 'device'}), + (0x04,Msg.CmdType.ON, 0x00, [True], {}), ]) - def test_handle_broadcast(self, test_device, group_num, cmd1, cmd2, expected): + def test_handle_broadcast(self, test_device, group_num, cmd1, cmd2, + expected, kwargs): with mock.patch.object(IM.Signal, 'emit') as mocked: self._is_wet = False flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) @@ -59,10 +69,8 @@ def test_handle_broadcast(self, test_device, group_num, cmd1, cmd2, expected): addr = IM.Address(0x01, 0x02, 0x03) msg = Msg.InpStandard(addr, group, flags, cmd1, cmd2) test_device.handle_broadcast(msg) - if expected is not None: - mocked.assert_called_once_with(test_device, *expected) - else: - mocked.assert_not_called() + mocked.assert_called_once_with(test_device, *expected, + **kwargs) def test_handle_heartbeat(self, test_device): # tests updating the wet/dry state when heartbeat received @@ -74,23 +82,27 @@ def test_handle_heartbeat(self, test_device): msg = Msg.InpStandard(addr, group, flags, Msg.CmdType.OFF, 0x00) test_device.handle_broadcast(msg) assert mocked.call_count == 2 - calls = [call(test_device, True), call(test_device, True)] + calls = [call(test_device, is_on=True, level=None, + mode=IM.on_off.Mode.NORMAL, button=2, reason=''), + call(test_device, True)] mocked.assert_has_calls(calls) def test_handle_refresh_not_wet(self, test_device): - with mock.patch.object(test_device, '_set_is_wet') as mocked: + with mock.patch.object(test_device, '_set_state') as mocked: flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) group = IM.Address(0x00, 0x00, 0x04) addr = IM.Address(0x01, 0x02, 0x03) msg = Msg.InpStandard(addr, group, flags, Msg.CmdType.OFF, 0x00) - test_device.handle_refresh(msg) - mocked.assert_called_once_with(False) + test_device.handle_refresh(msg, group=2) + mocked.assert_called_once_with(group=2, is_on=False, + reason='refresh') def test_handle_refresh_wet(self, test_device): - with mock.patch.object(test_device, '_set_is_wet') as mocked: + with mock.patch.object(test_device, '_set_state') as mocked: flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) group = IM.Address(0x00, 0x00, 0x04) addr = IM.Address(0x01, 0x02, 0x03) msg = Msg.InpStandard(addr, group, flags, Msg.CmdType.OFF, 0x11) - test_device.handle_refresh(msg) - mocked.assert_called_once_with(True) + test_device.handle_refresh(msg, group=2) + mocked.assert_called_once_with(group=2, is_on=True, + reason='refresh') diff --git a/tests/device/test_MotionDev.py b/tests/device/test_MotionDev.py index 415914a5..a45d1a49 100644 --- a/tests/device/test_MotionDev.py +++ b/tests/device/test_MotionDev.py @@ -45,9 +45,10 @@ def test_pair(self, test_device): assert IM.CommandSeq.add.call_count == 5 @pytest.mark.parametrize("group_num,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.ON, 0x00,{"is_on":True}), - (0x01,Msg.CmdType.OFF, 0x00, {"is_on":False}), - (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), + (0x01,Msg.CmdType.ON, 0x00,{"is_on":True, "level":None, "button":1, "reason":'device', + "mode":IM.on_off.Mode.NORMAL}), + (0x01,Msg.CmdType.OFF, 0x00, {"is_on":False, "level":None, "button":1, "reason":'device', + "mode":IM.on_off.Mode.NORMAL}), ]) def test_handle_broadcast_state(self, test_device, group_num, cmd1, cmd2, expected): with mock.patch.object(IM.Signal, 'emit') as mocked: @@ -65,13 +66,10 @@ def test_handle_broadcast_state(self, test_device, group_num, cmd1, cmd2, expect @pytest.mark.parametrize("group_num,cmd1,cmd2,expected", [ (0x02,Msg.CmdType.ON, 0x00,[True]), (0x02,Msg.CmdType.OFF, 0x00, [False]), - (0x02,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), (0x03,Msg.CmdType.ON, 0x00,[True]), (0x03,Msg.CmdType.OFF, 0x00, [False]), - (0x03,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), (0x04,Msg.CmdType.ON, 0x00,[True]), (0x04,Msg.CmdType.OFF, 0x00, [True]), - (0x04,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), ]) def test_handle_broadcast(self, test_device, group_num, cmd1, cmd2, expected): with mock.patch.object(IM.Signal, 'emit') as mocked: @@ -234,3 +232,122 @@ def on_done(*args): test_device.auto_check_battery() sent = test_device.protocol.sent assert len(sent) == 0 + + def test_set_flags_extended(self, test_device): + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device.set_flags(None, led_on=1) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == Msg.CmdType.EXTENDED_SET_GET + assert test_device.protocol.sent[0].msg.cmd2 == 0x00 + # already test extended flags above + + def test_change_flags_led_on(self, test_device): + test_device.led_on = 1 + test_device.night_only = 1 + test_device.on_only = 1 + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._change_flags({'led_on':0}) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == Msg.CmdType.EXTENDED_SET_GET + assert test_device.protocol.sent[0].msg.cmd2 == 0x00 + assert test_device.protocol.sent[0].msg.data[1] == 0x05 # Set flags + assert test_device.protocol.sent[0].msg.data[2] == 0x06 + + def test_change_flags_night_only(self, test_device): + test_device.led_on = 1 + test_device.night_only = 1 + test_device.on_only = 1 + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._change_flags({'night_only':0}) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == Msg.CmdType.EXTENDED_SET_GET + assert test_device.protocol.sent[0].msg.cmd2 == 0x00 + assert test_device.protocol.sent[0].msg.data[1] == 0x05 # Set flags + assert test_device.protocol.sent[0].msg.data[2] == 0x0A + + def test_change_flags_on_only(self, test_device): + test_device.led_on = 1 + test_device.night_only = 1 + test_device.on_only = 1 + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._change_flags({'on_only':0}) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == Msg.CmdType.EXTENDED_SET_GET + assert test_device.protocol.sent[0].msg.cmd2 == 0x00 + assert test_device.protocol.sent[0].msg.data[1] == 0x05 # Set flags + assert test_device.protocol.sent[0].msg.data[2] == 0x0C + + def test_change_flags_bad(self, test_device, caplog): + test_device.led_on = 1 + test_device.night_only = 1 + test_device.on_only = 1 + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._change_flags({'on_only':'bad value'}, on_done=on_done) + assert len(test_device.protocol.sent) == 0 + assert 'Invalid on only' in caplog.text + + def test_set_timeout(self, test_device): + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._set_timeout(timeout=20) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == Msg.CmdType.EXTENDED_SET_GET + assert test_device.protocol.sent[0].msg.cmd2 == 0x00 + assert test_device.protocol.sent[0].msg.data[1] == 0x03 # Set timeout + assert test_device.protocol.sent[0].msg.data[2] == 0x02 + + def test_set_timeout_bad(self, test_device, caplog): + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._set_timeout(timeout='not a number', on_done=on_done) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 0 + assert 'Invalid timeout' in caplog.text + + def test_sensitivity(self, test_device): + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._set_light_sens(light_sensitivity=20) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == Msg.CmdType.EXTENDED_SET_GET + assert test_device.protocol.sent[0].msg.cmd2 == 0x00 + assert test_device.protocol.sent[0].msg.data[1] == 0x04 # Set timeout + assert test_device.protocol.sent[0].msg.data[2] == 0x14 + + def test_sensitivity_bad(self, test_device, caplog): + def on_done(*args): + pass + # Mark awake so messages get sent to protocol + test_device.awake(on_done) + test_device._set_light_sens(light_sensitivity='not a number', + on_done=on_done) + # should see an ext flag request first + assert len(test_device.protocol.sent) == 0 + assert 'Invalid light sensitivity' in caplog.text diff --git a/tests/device/test_OutletDev.py b/tests/device/test_OutletDev.py index 24c4a073..dda3914e 100644 --- a/tests/device/test_OutletDev.py +++ b/tests/device/test_OutletDev.py @@ -40,13 +40,13 @@ def test_pair(self, test_device): assert IM.CommandSeq.add.call_count == 3 @pytest.mark.parametrize("group_num,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL, "reason":'device',"button":1}), - (0x01,Msg.CmdType.OFF, 0x00, {"is_on":0,"mode":IM.on_off.Mode.NORMAL, "reason":'device',"button":1}), - (0x01,Msg.CmdType.ON_FAST, 0x00,{"is_on":True,"mode":IM.on_off.Mode.FAST, "reason":'device',"button":1}), - (0x01,Msg.CmdType.OFF_FAST, 0x00, {"is_on":0,"mode":IM.on_off.Mode.FAST, "reason":'device',"button":1}), + (0x01,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL, "level": None, "reason":'device',"button":1}), + (0x01,Msg.CmdType.OFF, 0x00, {"is_on":0,"mode":IM.on_off.Mode.NORMAL, "level": None, "reason":'device',"button":1}), + (0x01,Msg.CmdType.ON_FAST, 0x00,{"is_on":True,"mode":IM.on_off.Mode.FAST, "level": None, "reason":'device',"button":1}), + (0x01,Msg.CmdType.OFF_FAST, 0x00, {"is_on":0,"mode":IM.on_off.Mode.FAST, "level": None, "reason":'device',"button":1}), (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x00, None), (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), - (0x02,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL, "reason":'device',"button":2}), + (0x02,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL, "level": None, "reason":'device',"button":2}), ]) def test_handle_on_off(self, test_device, group_num, cmd1, cmd2, expected): with mock.patch.object(IM.Signal, 'emit') as mocked: @@ -62,7 +62,7 @@ def test_handle_on_off(self, test_device, group_num, cmd1, cmd2, expected): def test_set_backlight(self, test_device): # set_backlight(self, level, on_done=None) - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x20 assert test_device.protocol.sent[0].msg.cmd2 == 0x08 @@ -78,7 +78,7 @@ def level_bytes(level): for params in ([1, 0x01], [255, 0xFF], [127, 127]): with mock.patch.object(IM.CommandSeq, 'add_msg'): - test_device.set_backlight(params[0]) + test_device.set_backlight(backlight=params[0]) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 2 # Check the first call @@ -91,7 +91,7 @@ def level_bytes(level): with mock.patch.object(IM.CommandSeq, 'add_msg'): # test backlight off - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 1 # Check the first call diff --git a/tests/device/test_RemoteDev.py b/tests/device/test_RemoteDev.py index ec3b5f3f..5d4ac1a2 100644 --- a/tests/device/test_RemoteDev.py +++ b/tests/device/test_RemoteDev.py @@ -8,7 +8,7 @@ from unittest import mock from unittest.mock import call import insteon_mqtt as IM -import insteon_mqtt.device.Remote as Remote +from insteon_mqtt.device.Remote import Remote import insteon_mqtt.message as Msg import insteon_mqtt.util as util import helpers as H @@ -79,21 +79,46 @@ def test_pair8(self, test_device8): assert IM.CommandSeq.add.call_count == 9 @pytest.mark.parametrize("group_num,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":1}), - (0x01,Msg.CmdType.OFF, 0x00, {"is_on":False,"mode":IM.on_off.Mode.NORMAL,"button":1}), - (0x01,Msg.CmdType.ON_FAST, 0x00,{"is_on":True,"mode":IM.on_off.Mode.FAST,"button":1}), - (0x01,Msg.CmdType.OFF_FAST, 0x00, {"is_on":False,"mode":IM.on_off.Mode.FAST,"button":1}), - (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.DOWN,"button":1}), - (0x01,Msg.CmdType.START_MANUAL_CHANGE, 0x01, {"manual":IM.on_off.Manual.UP,"button":1}), - (0x01,Msg.CmdType.STOP_MANUAL_CHANGE, 0x00, {"manual":IM.on_off.Manual.STOP,"button":1}), + (0x01,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":1}), + (0x01,Msg.CmdType.OFF, 0x00, {"is_on":False,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL, + "button":1}), + (0x01,Msg.CmdType.ON_FAST, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.FAST, + "button":1}), + (0x01,Msg.CmdType.OFF_FAST, 0x00, {"is_on":False,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.FAST, + "button":1}), + (0x01,Msg.CmdType.STOP_MANUAL_CHANGE, 0x00, { + "manual":IM.on_off.Manual.STOP,"button":1, "reason":'device'} + ), (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), - (0x02,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":2}), - (0x03,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":3}), - (0x04,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":4}), - (0x05,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":5}), - (0x06,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":6}), - (0x07,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":7}), - (0x08,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL,"button":8}), + (0x02,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":2}), + (0x03,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":3}), + (0x04,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":4}), + (0x05,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":5}), + (0x06,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":6}), + (0x07,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":7}), + (0x08,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None, + "reason":'device', + "mode":IM.on_off.Mode.NORMAL,"button":8}), ]) def test_handle_on_off(self, test_device8, group_num, cmd1, cmd2, expected): with mock.patch.object(IM.Signal, 'emit') as mocked: @@ -106,3 +131,34 @@ def test_handle_on_off(self, test_device8, group_num, cmd1, cmd2, expected): mocked.assert_called_once_with(test_device8, **expected) else: mocked.assert_not_called() + + + def test_handle_manual(self, test_device8): + with mock.patch.object(IM.Signal, 'emit') as mocked: + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.START_MANUAL_CHANGE, 0x00) + test_device8.handle_broadcast(msg) + calls = [ + call(test_device8, manual=IM.on_off.Manual.DOWN, button=1, + reason='device'), + call(test_device8, button=1, is_on=False, level=None, + reason='device', mode=IM.on_off.Mode.MANUAL) + ] + mocked.assert_has_calls(calls, any_order=True) + with mock.patch.object(IM.Signal, 'emit') as mocked: + flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) + group = IM.Address(0x00, 0x00, 0x01) + addr = IM.Address(0x01, 0x02, 0x03) + msg = Msg.InpStandard(addr, group, flags, + Msg.CmdType.START_MANUAL_CHANGE, 0x01) + test_device8.handle_broadcast(msg) + calls = [ + call(test_device8, manual=IM.on_off.Manual.UP, button=1, + reason='device'), + call(test_device8, button=1, is_on=True, level=None, + reason='device', mode=IM.on_off.Mode.MANUAL) + ] + mocked.assert_has_calls(calls, any_order=True) diff --git a/tests/device/test_SwitchDev.py b/tests/device/test_SwitchDev.py index a5611846..1cc41abd 100644 --- a/tests/device/test_SwitchDev.py +++ b/tests/device/test_SwitchDev.py @@ -38,10 +38,10 @@ def test_pair(self, test_device): assert IM.CommandSeq.add.call_count == 2 @pytest.mark.parametrize("group,cmd1,cmd2,expected", [ - (0x01,Msg.CmdType.ON, 0x00,{"is_on":True,"mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (0x01,Msg.CmdType.OFF, 0x00, {"is_on":False,"mode":IM.on_off.Mode.NORMAL, "reason":'device'}), - (0x01,Msg.CmdType.ON_FAST, 0x00,{"is_on":True,"mode":IM.on_off.Mode.FAST, "reason":'device'}), - (0x01,Msg.CmdType.OFF_FAST, 0x00, {"is_on":False,"mode":IM.on_off.Mode.FAST, "reason":'device'}), + (0x01,Msg.CmdType.ON, 0x00,{"is_on":True,"level":None,"mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.OFF, 0x00, {"is_on":False,"level":None,"mode":IM.on_off.Mode.NORMAL, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.ON_FAST, 0x00,{"is_on":True,"level":None,"mode":IM.on_off.Mode.FAST, "button":1, "reason":'device'}), + (0x01,Msg.CmdType.OFF_FAST, 0x00, {"is_on":False,"level":None,"mode":IM.on_off.Mode.FAST, "button":1, "reason":'device'}), (0x01,Msg.CmdType.LINK_CLEANUP_REPORT, 0x00, None), ]) def test_handle_on_off(self, test_device, group, cmd1, cmd2, expected): @@ -64,8 +64,10 @@ def test_handle_on_off_manual(self, test_device): msg = Msg.InpStandard(addr, group, flags, Msg.CmdType.START_MANUAL_CHANGE, 0x00) test_device.handle_broadcast(msg) assert mocked.call_count == 2 - calls = [call(test_device, manual=IM.on_off.Manual.DOWN), - call(test_device, is_on=False, mode=IM.on_off.Mode.MANUAL, reason='device')] + calls = [call(test_device, button=1, manual=IM.on_off.Manual.DOWN, + reason='device'), + call(test_device, is_on=False, level=None, + mode=IM.on_off.Mode.MANUAL, button=1, reason='device')] mocked.assert_has_calls(calls) with mock.patch.object(IM.Signal, 'emit') as mocked: flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) @@ -74,13 +76,14 @@ def test_handle_on_off_manual(self, test_device): msg = Msg.InpStandard(addr, group, flags, Msg.CmdType.START_MANUAL_CHANGE, 0x01) test_device.handle_broadcast(msg) assert mocked.call_count == 2 - calls = [call(test_device, manual=IM.on_off.Manual.UP), - call(test_device, is_on=True, mode=IM.on_off.Mode.MANUAL, reason='device')] + calls = [call(test_device, button=1, manual=IM.on_off.Manual.UP, + reason='device'), + call(test_device, is_on=True, button=1, level=None, + mode=IM.on_off.Mode.MANUAL, reason='device')] mocked.assert_has_calls(calls) def test_set_backlight(self, test_device): - # set_backlight(self, level, on_done=None) - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) assert len(test_device.protocol.sent) == 1 assert test_device.protocol.sent[0].msg.cmd1 == 0x20 assert test_device.protocol.sent[0].msg.cmd2 == 0x08 @@ -96,7 +99,7 @@ def level_bytes(level): for params in ([1, 0x01], [255, 0xFF], [127, 127]): with mock.patch.object(IM.CommandSeq, 'add_msg'): - test_device.set_backlight(params[0]) + test_device.set_backlight(backlight=params[0]) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 2 # Check the first call @@ -109,9 +112,26 @@ def level_bytes(level): with mock.patch.object(IM.CommandSeq, 'add_msg'): # test backlight off - test_device.set_backlight(0) + test_device.set_backlight(backlight=0) args_list = IM.CommandSeq.add_msg.call_args_list assert IM.CommandSeq.add_msg.call_count == 1 # Check the first call assert args_list[0][0][0].cmd1 == 0x20 assert args_list[0][0][0].cmd2 == 0x08 + + def test_set_backlight_bad(self, test_device, caplog): + def on_done(success, msg, data): + assert not success + test_device.set_backlight(backlight='badstring', on_done=on_done) + assert 'Invalid backlight level' in caplog.text + + def test_set_flags(self, test_device): + test_device.set_flags(None, backlight=0) + assert len(test_device.protocol.sent) == 1 + assert test_device.protocol.sent[0].msg.cmd1 == 0x20 + assert test_device.protocol.sent[0].msg.cmd2 == 0x08 + + def test_set_flags_bad(self, test_device, caplog): + test_device.set_flags(None, bad=0) + assert len(test_device.protocol.sent) == 0 + assert 'Unknown set flags input' in caplog.text diff --git a/tests/device/test_ThermostatDev.py b/tests/device/test_ThermostatDev.py index 6d21a575..0eae744f 100644 --- a/tests/device/test_ThermostatDev.py +++ b/tests/device/test_ThermostatDev.py @@ -47,7 +47,7 @@ def test_basic(self, tmpdir): # Lightly test pairing and I mean light. Most of this testing is # handled by other tests thermo.pair() - msg = Msg.OutStandard.direct(addr, 0x19, 0x03) + msg = Msg.OutStandard.direct(addr, 0x19, 0x00) test_msg = protocol.msgs.pop(0) assert test_msg.to_bytes() == msg.to_bytes() diff --git a/tests/handler/test_Broadcast.py b/tests/handler/test_Broadcast.py index e281fa23..858a2650 100644 --- a/tests/handler/test_Broadcast.py +++ b/tests/handler/test_Broadcast.py @@ -22,7 +22,6 @@ def test_acks(self, tmpdir, caplog): r = handler.msg_received(proto, "dummy") assert r == Msg.UNKNOWN - assert len(calls) == 0 flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) msg = Msg.InpStandard(addr, broadcast_to_addr, flags, 0x11, 0x01) @@ -30,11 +29,11 @@ def test_acks(self, tmpdir, caplog): # no device r = handler.msg_received(proto, msg) assert r == Msg.UNKNOWN - assert len(calls) == 0 # test good broadcat assert proto.wait_time == 0 - device = IM.device.Base(proto, modem, addr, "foo") + device = IM.device.base.Base(proto, modem, addr, "foo") + device.handle_broadcast = calls.append # add 10 device db entries for this group for count in range(10): @@ -45,7 +44,6 @@ def test_acks(self, tmpdir, caplog): bytes([0x01, 0x02, 0x03])) device.db.add_entry(entry) - device.handle_broadcast = calls.append modem.add(device) r = handler.msg_received(proto, msg) @@ -76,33 +74,37 @@ def test_acks(self, tmpdir, caplog): assert r == Msg.CONTINUE assert len(calls) == 2 + # A direct message should be unknown flags = Msg.Flags(Msg.Flags.Type.DIRECT_ACK, False) msg = Msg.InpStandard(addr, addr, flags, 0x11, 0x01) r = handler.msg_received(proto, msg) assert r == Msg.UNKNOWN + assert len(calls) == 2 - # Success Report Broadcast + # Success Report Broadcast, should not be sent to device pre_success_time = proto.wait_time flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) success_report_to_addr = IM.Address(0x11, 1, 0x1) - msg = Msg.InpStandard(addr, addr, flags, 0x06, 0x00) + msg = Msg.InpStandard(addr, addr, flags, + Msg.CmdType.LINK_CLEANUP_REPORT, 0x00) r = handler.msg_received(proto, msg) assert r == Msg.CONTINUE - assert len(calls) == 3 + assert len(calls) == 2 # wait time should be cleared assert proto.wait_time < pre_success_time - # Failure Report Broadcast + # Failure Report Broadcast, should not be sent to device pre_success_time = proto.wait_time flags = Msg.Flags(Msg.Flags.Type.ALL_LINK_BROADCAST, False) success_report_to_addr = IM.Address(0x11, 1, 0x1) - msg = Msg.InpStandard(addr, addr, flags, 0x06, 0x01) + msg = Msg.InpStandard(addr, addr, flags, + Msg.CmdType.LINK_CLEANUP_REPORT, 0x01) r = handler.msg_received(proto, msg) assert 'Cleanup report for 0a.12.34, grp 52 had 1 fails.' in caplog.text assert r == Msg.CONTINUE - assert len(calls) == 4 + assert len(calls) == 2 # Pretend that a new broadcast message dropped / not received by PLM @@ -112,7 +114,7 @@ def test_acks(self, tmpdir, caplog): r = handler.msg_received(proto, msg) assert r == Msg.CONTINUE - assert len(calls) == 5 + assert len(calls) == 3 #----------------------------------------------------------------------- diff --git a/tests/handler/test_BroadcastCmdResponse.py b/tests/handler/test_BroadcastCmdResponse.py index c2eea735..ea23c640 100644 --- a/tests/handler/test_BroadcastCmdResponse.py +++ b/tests/handler/test_BroadcastCmdResponse.py @@ -130,6 +130,52 @@ def callback(msg, on_done=None): assert r == Msg.CONTINUE assert handler._PLM_ACK + def test_device_sent_ack(self): + # Tests matching the command from the outbound message. + proto = MockProto() + calls = [] + + def callback(msg, on_done=None): + calls.append(msg) + + addr = IM.Address('0a.12.34') + + # sent message, match input command + out = Msg.OutStandard.direct(addr, 0x10, 0x00) + handler = IM.handler.BroadcastCmdResponse(out, callback) + + # Signal Sent + handler.sending_message(out) + assert handler._PLM_sent + + # test ack sent + out.is_ack = True + r = handler.msg_received(proto, out) + assert r == Msg.CONTINUE + assert handler._PLM_ACK + + # test broadcast before device ack + flags = Msg.Flags(Msg.Flags.Type.BROADCAST, False) + msg = Msg.InpStandard(addr, addr, flags, 0x01, 0x00) + r = handler.msg_received(proto, msg) + assert r == Msg.UNKNOWN + assert len(calls) == 0 + + # mock a device ack + # expected input meesage + flags = Msg.Flags(Msg.Flags.Type.DIRECT_ACK, False) + msg = Msg.InpStandard(addr, addr, flags, 0x10, 0x00) + r = handler.msg_received(proto, msg) + assert r == Msg.CONTINUE + + # test a broadcast after device ack + flags = Msg.Flags(Msg.Flags.Type.BROADCAST, False) + msg = Msg.InpStandard(addr, addr, flags, 0x01, 0x00) + r = handler.msg_received(proto, msg) + assert r == Msg.FINISHED + assert len(calls) == 1 + assert calls[0] == msg + #=========================================================================== diff --git a/tests/handler/test_ModemGetFlags.py b/tests/handler/test_ModemGetFlags.py new file mode 100644 index 00000000..15abf1d5 --- /dev/null +++ b/tests/handler/test_ModemGetFlags.py @@ -0,0 +1,70 @@ +#=========================================================================== +# +# Tests for: insteont_mqtt/handler/ModemGetFlags.py +# +# pylint: disable=attribute-defined-outside-init +#=========================================================================== +import insteon_mqtt as IM +import insteon_mqtt.message as Msg +import helpers as H + + +class Test_ModemGetFlags: + def test_acks(self, tmpdir): + calls = [] + + def callback(success, msg, done): + calls.append((success, msg, done)) + + modem = H.main.MockModem(tmpdir) + proto = H.main.MockProtocol() + handler = IM.handler.ModemGetFlags(modem, callback) + handler._PLM_sent = True + handler._PLM_ACK = True + + #Try a good message + msg = Msg.OutGetModemFlags(is_ack=True, modem_flags=0x01, spare1=0x02, + spare2=0x03) + r = handler.msg_received(proto, msg) + assert r == Msg.FINISHED + assert calls[0][0] + + #Try a NAK message + msg = Msg.OutGetModemFlags(is_ack=False, modem_flags=0x01, spare1=0x02, + spare2=0x03) + r = handler.msg_received(proto, msg) + assert r == Msg.FINISHED + assert not calls[1][0] + + #Wrong Message + msg = Msg.OutResetModem(is_ack=True) + r = handler.msg_received(proto, msg) + assert r == Msg.UNKNOWN + + #----------------------------------------------------------------------- + def test_plm_sent(self, tmpdir): + calls = [] + + def callback(success, msg, done): + calls.append((success, msg, done)) + + modem = H.main.MockModem(tmpdir) + proto = H.main.MockProtocol() + handler = IM.handler.ModemGetFlags(modem, callback) + assert not handler._PLM_sent + + #Try a message prior to sent + msg = Msg.OutGetModemFlags(is_ack=False, modem_flags=0x01, spare1=0x02, + spare2=0x03) + r = handler.msg_received(proto, msg) + assert r == Msg.UNKNOWN + + # Signal Sent + handler.sending_message(msg) + assert handler._PLM_sent + + #Try a message prior to sent + msg = Msg.OutGetModemFlags(is_ack=False, modem_flags=0x01, spare1=0x02, + spare2=0x03) + r = handler.msg_received(proto, msg) + assert r == Msg.FINISHED diff --git a/tests/handler/test_StandardCmd.py b/tests/handler/test_StandardCmd.py index c2f2f27e..ee7ba71b 100644 --- a/tests/handler/test_StandardCmd.py +++ b/tests/handler/test_StandardCmd.py @@ -144,7 +144,7 @@ def test_plm_sent_ack(self, tmpdir): modem = MockModem(tmpdir) calls = [] addr = IM.Address('0a.12.34') - device = IM.device.Base(proto, modem, addr) + device = IM.device.base.Base(proto, modem, addr) def callback(success, msg, data): calls.append(msg) @@ -180,7 +180,7 @@ def test_engine_version(self, tmpdir): modem = MockModem(tmpdir) calls = [] addr = IM.Address('0a.12.34') - device = IM.device.Base(proto, modem, addr) + device = IM.device.base.Base(proto, modem, addr) def callback(success, msg, data): calls.append(msg) diff --git a/tests/message/test_OutGetModemFlags.py b/tests/message/test_OutGetModemFlags.py new file mode 100644 index 00000000..82b4d64e --- /dev/null +++ b/tests/message/test_OutGetModemFlags.py @@ -0,0 +1,47 @@ +#=========================================================================== +# +# Tests for: insteont_mqtt/message/OutGetModemFlags.py +# +#=========================================================================== +import insteon_mqtt.message as Msg + + +class Test_OutGetModemFlags: + #----------------------------------------------------------------------- + def test_out(self): + obj = Msg.OutGetModemFlags() + assert obj.fixed_msg_size == 6 + + b = obj.to_bytes() + rt = bytes([0x02, 0x73]) + assert b == rt + + str(obj) + + #----------------------------------------------------------------------- + def test_in_ack(self): + b = bytes([0x02, 0x73, 0x01, 0x02, 0x03, 0x06]) + obj = Msg.OutGetModemFlags.from_bytes(b) + assert obj.is_ack is True + assert obj.modem_flags == 0x01 + assert obj.spare1 == 0x02 + assert obj.spare2 == 0x03 + + str(obj) + + #----------------------------------------------------------------------- + def test_in_nack(self): + b = bytes([0x02, 0x73, 0x01, 0x02, 0x03, 0x15]) + obj = Msg.OutGetModemFlags.from_bytes(b) + assert obj.is_ack is False + assert obj.modem_flags == 0x01 + assert obj.spare1 == 0x02 + assert obj.spare2 == 0x03 + + str(obj) + + + #----------------------------------------------------------------------- + + +#=========================================================================== diff --git a/tests/mqtt/test_IOLincMqtt.py b/tests/mqtt/test_IOLincMqtt.py index 6fc05bd4..c9715c35 100644 --- a/tests/mqtt/test_IOLincMqtt.py +++ b/tests/mqtt/test_IOLincMqtt.py @@ -59,20 +59,40 @@ def test_pubsub(self, setup): def test_template(self, setup): mdev, addr, name = setup.getAll(['mdev', 'addr', 'name']) - data = mdev.template_data() + data = mdev.base_template_data() right = {"address" : addr.hex, "name" : name} assert data == right - data = mdev.template_data(relay_is_on=True, sensor_is_on=True) + data = mdev.state_template_data(button=1, is_on=True) right = {"address" : addr.hex, "name" : name, - "relay_on" : 1, "relay_on_str" : "on", - "sensor_on" : 1, "sensor_on_str" : "on"} + "sensor_on" : 1, "sensor_on_str" : "on", "button":1, + "fast": 0, "instant": 0, "mode": "normal", "on": 1, + "on_str": "on", "reason": "", "relay_on": 0, + "relay_on_str" : "off"} assert data == right - data = mdev.template_data(relay_is_on=False, sensor_is_on=False) + data = mdev.state_template_data(button=2, is_on=True) right = {"address" : addr.hex, "name" : name, - "relay_on" : 0, "relay_on_str" : "off", - "sensor_on" : 0, "sensor_on_str" : "off"} + "sensor_on" : 0, "sensor_on_str" : "off", "button": 2, + "fast": 0, "instant": 0, "mode": "normal", "on": 1, + "on_str": "on", "reason": "", "relay_on": 1, + "relay_on_str" : "on"} + assert data == right + + data = mdev.state_template_data(button=1, is_on=False) + right = {"address" : addr.hex, "name" : name, + "sensor_on" : 0, "sensor_on_str" : "off", "button": 1, + "fast": 0, "instant": 0, "mode": "normal", "on": 0, + "on_str": "off", "reason": "", "relay_on": 0, + "relay_on_str" : "off"} + assert data == right + + data = mdev.state_template_data(button=2, is_on=False) + right = {"address" : addr.hex, "name" : name, + "sensor_on" : 0, "sensor_on_str" : "off", "button": 2, + "fast": 0, "instant": 0, "mode": "normal", "on": 0, + "on_str": "off", "reason": "", "relay_on": 0, + "relay_on_str" : "off"} assert data == right #----------------------------------------------------------------------- @@ -84,17 +104,17 @@ def test_mqtt(self, setup): mdev.load_config({}) # Send an on/off signal - dev.signal_on_off.emit(dev, True, True) - dev.signal_on_off.emit(dev, False, False) + dev.signal_state.emit(dev, button=1, is_on=True) + dev.signal_state.emit(dev, button=2, is_on=True) # 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='{"sensor": "on", "relay": "on"}', + payload='{"sensor":"on", "relay":"off"}', qos=0, retain=True) assert link.client.pub[1] == dict( topic='%s/relay' % topic, - payload='on', + payload='off', qos=0, retain=True) assert link.client.pub[2] == dict( topic='%s/sensor' % topic, @@ -102,11 +122,11 @@ def test_mqtt(self, setup): qos=0, retain=True) assert link.client.pub[3] == dict( topic='%s/state' % topic, - payload='{"sensor": "off", "relay": "off"}', + payload='{"sensor":"off", "relay":"on"}', qos=0, retain=True) assert link.client.pub[4] == dict( topic='%s/relay' % topic, - payload='off', + payload='on', qos=0, retain=True) assert link.client.pub[5] == dict( topic='%s/sensor' % topic, @@ -131,24 +151,51 @@ def test_config(self, setup): stopic = "foo/%s" % setup.addr.hex - # Send an on/off signal - dev.signal_on_off.emit(dev, True, True) - dev.signal_on_off.emit(dev, False, False) - assert len(link.client.pub) == 6 + # sensor on + dev.signal_state.emit(dev, button=1, is_on=True) + assert len(link.client.pub) == 3 + assert link.client.pub[0] == dict( + topic=stopic, payload='0 OFF', qos=qos, retain=True) + assert link.client.pub[1] == dict( + topic=stopic + "/relay", payload='0 OFF', qos=qos, retain=True) + assert link.client.pub[2] == dict( + topic=stopic + "/sensor", payload='1 ON', qos=qos, retain=True) + link.client.clear() + + # sensor off + dev.signal_state.emit(dev, button=1, is_on=False) + assert len(link.client.pub) == 3 + assert link.client.pub[0] == dict( + topic=stopic, payload='0 OFF', qos=qos, retain=True) + assert link.client.pub[1] == dict( + topic=stopic + "/relay", payload='0 OFF', qos=qos, retain=True) + assert link.client.pub[2] == dict( + topic=stopic + "/sensor", payload='0 OFF', qos=qos, retain=True) + link.client.clear() + + # relay on + dev.signal_state.emit(dev, button=2, is_on=True) + assert len(link.client.pub) == 3 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 + "/sensor", payload='0 OFF', qos=qos, retain=True) + link.client.clear() + + # relay off + dev.signal_state.emit(dev, button=2, is_on=False) + assert len(link.client.pub) == 3 + assert link.client.pub[0] == dict( topic=stopic, payload='0 OFF', qos=qos, retain=True) - assert link.client.pub[4] == dict( + assert link.client.pub[1] == dict( topic=stopic + "/relay", payload='0 OFF', qos=qos, retain=True) - assert link.client.pub[5] == dict( + assert link.client.pub[2] == dict( topic=stopic + "/sensor", payload='0 OFF', qos=qos, retain=True) link.client.clear() + #----------------------------------------------------------------------- def test_input_on_off(self, setup): mdev, link, proto, addr = setup.getAll(['mdev', 'link', 'proto', diff --git a/tests/mqtt/test_KeypadLinc.py b/tests/mqtt/test_KeypadLinc.py index 4874a1fd..aa8c88b5 100644 --- a/tests/mqtt/test_KeypadLinc.py +++ b/tests/mqtt/test_KeypadLinc.py @@ -27,12 +27,12 @@ def setup(mock_paho_mqtt, tmpdir): modem = H.main.MockModem(tmpdir) addr = IM.Address(1, 2, 3) name = "device name" - dev = IM.device.KeypadLinc(proto, modem, addr, name, dimmer=True) + dev = IM.device.KeypadLincDimmer(proto, modem, addr, name) link = IM.network.Mqtt() mqttModem = H.mqtt.MockModem() mqtt = IM.mqtt.Mqtt(link, mqttModem) - mdev = IM.mqtt.KeypadLinc(mqtt, dev) + mdev = IM.mqtt.KeypadLincDimmer(mqtt, dev) return H.Data(addr=addr, name=name, dev=dev, mdev=mdev, link=link, proto=proto, modem=modem) @@ -318,7 +318,7 @@ def test_input_with_default_on_level(self, setup): ack = IM.message.InpStandard(dev.addr.hex, dev.modem.addr.hex, flags, cmd1, cmd2) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) assert dev._level == cmd2 #----------------------------------------------------------------------- @@ -395,7 +395,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x2f, 0x08) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -417,7 +417,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x2e, 0xf5) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -439,7 +439,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x2f, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -461,7 +461,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x2e, 0x4e) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -483,7 +483,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x2f, 0x0d) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -505,7 +505,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x2e, 0xfd) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -531,7 +531,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x14, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -554,7 +554,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x21, 0x43) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 assert link.client.pub[1] == dict( @@ -575,7 +575,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x14, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -598,7 +598,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x21, 0x43) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 assert link.client.pub[1] == dict( @@ -791,6 +791,29 @@ def test_input_level(self, setup): link.publish("foo/%s/1" % addr.hex, b'{ "on" : "foo", "num" : "bad" }', qos, False) + #----------------------------------------------------------------------- + def test_input_level_detached_load(self, setup): + mdev, link, addr, proto, dev = setup.getAll(['mdev', 'link', 'addr', + 'proto', 'dev']) + + qos = 2 + config = {'keypad_linc' : { + 'dimmer_level_topic' : 'foo/{{address}}/1', + 'dimmer_level_payload' : ('{ "cmd" : "{{json.on.lower()}}",' + '"level" : "{{json.num}}" }')}} + mdev.load_config(config, qos=qos) + + mdev.subscribe(link, qos) + + # Set device in detached load state + dev._load_group = 9 + + payload = b'{ "on" : "OFF", "num" : 0 }' + link.publish("foo/%s/1" % addr.hex, payload, qos, retain=False) + # This should still trigger a standard off command + assert len(proto.sent) == 1 + assert proto.sent[0].msg.cmd1 == 0x13 + #----------------------------------------------------------------------- def test_input_level_reason(self, setup): mdev, link, addr, proto = setup.getAll(['mdev', 'link', 'addr', diff --git a/tests/mqtt/test_KeypadLinc_sw.py b/tests/mqtt/test_KeypadLinc_sw.py index cf27705e..041acb6c 100644 --- a/tests/mqtt/test_KeypadLinc_sw.py +++ b/tests/mqtt/test_KeypadLinc_sw.py @@ -27,7 +27,7 @@ def setup(mock_paho_mqtt, tmpdir): modem = H.main.MockModem(tmpdir) addr = IM.Address(1, 2, 3) name = "device name" - dev = IM.device.KeypadLinc(proto, modem, addr, name, dimmer=False) + dev = IM.device.KeypadLinc(proto, modem, addr, name) link = IM.network.Mqtt() mqttModem = H.mqtt.MockModem() @@ -233,7 +233,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x13, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -255,7 +255,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x11, 0xff) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -277,7 +277,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x13, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -299,7 +299,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x11, 0xff) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -321,7 +321,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x13, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -343,7 +343,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x11, 0xff) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -369,7 +369,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x14, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -392,7 +392,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x21, 0xff) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 assert link.client.pub[1] == dict( @@ -413,7 +413,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x14, 0x00) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 @@ -436,7 +436,7 @@ def test_input_transition(self, setup): flags = IM.message.Flags(IM.message.Flags.Type.DIRECT_ACK, False) ack = IM.message.InpStandard(setup.addr.hex, modem.addr.hex, flags, 0x21, 0xff) - dev.handle_set_load(ack, IM.util.make_callback(None)) + dev.handle_ack(ack, IM.util.make_callback(None)) # Check that reported state matches command assert len(link.client.pub) == 2 assert link.client.pub[1] == dict( diff --git a/tests/mqtt/test_Leak.py b/tests/mqtt/test_Leak.py index 79bbe533..b7c7207b 100644 --- a/tests/mqtt/test_Leak.py +++ b/tests/mqtt/test_Leak.py @@ -67,10 +67,12 @@ def test_template(self, setup): assert data == right pytest.approx(t0, hb, 5) - data = mdev.template_data_leak(is_wet=False) + data = mdev.state_template_data(button=2, is_on=False) right = {"address" : addr.hex, "name" : name, "is_wet" : 0, "is_wet_str" : "off", "state" : "dry", - "is_dry" : 1, "is_dry_str" : "on"} + "is_dry" : 1, "is_dry_str" : "on", "button": 2, + "fast": 0, "instant": 0, "mode": 'normal', "on": 0, + "on_str": 'off', "reason": ''} assert data == right #----------------------------------------------------------------------- @@ -83,8 +85,8 @@ def test_mqtt(self, setup): mdev.load_config({}) # Send an on/off signal - dev.signal_wet.emit(dev, True) - dev.signal_wet.emit(dev, False) + dev.signal_state.emit(dev, button=2, is_on=True) + dev.signal_state.emit(dev, button=2, is_on=False) assert len(link.client.pub) == 2 assert link.client.pub[0] == dict( topic='%s/wet' % topic, payload='on', qos=0, retain=True) @@ -107,6 +109,24 @@ def test_mqtt(self, setup): assert m == dict(topic='%s/heartbeat' % topic, qos=0, retain=True) pytest.approx(t0, hb, 5) + #----------------------------------------------------------------------- + def test_refresh_data(self, setup): + # handle refresh will pass the level and not an is_on + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + + topic = "insteon/%s" % setup.addr.hex + + # Should do nothing + mdev.load_config({}) + + # Send an on/off signal I actually think this would be level=0xff + dev.signal_state.emit(dev, button=2, level=0x11, reason='refresh') + assert len(link.client.pub) == 1 + assert link.client.pub[0] == dict( + topic='%s/wet' % topic, payload='on', qos=0, retain=True) + + link.client.clear() + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) @@ -123,8 +143,8 @@ def test_config(self, setup): htopic = "bar/%s" % setup.addr.hex # Send an on/off signal - dev.signal_wet.emit(dev, True) - dev.signal_wet.emit(dev, False) + dev.signal_state.emit(dev, button=2, is_on=True) + dev.signal_state.emit(dev, button=1, is_on=True) assert len(link.client.pub) == 2 assert link.client.pub[0] == dict( topic=stopic, payload='1 ON', qos=qos, retain=True) diff --git a/tests/test_Scenes.py b/tests/test_Scenes.py index c66cec88..9cfc1237 100644 --- a/tests/test_Scenes.py +++ b/tests/test_Scenes.py @@ -13,10 +13,11 @@ import insteon_mqtt.db.DeviceEntry as DeviceEntry import insteon_mqtt.db.Modem as ModemDB import insteon_mqtt.db.ModemEntry as ModemEntry -import insteon_mqtt.device.Base as Base +import insteon_mqtt.device.base.Base as Base import insteon_mqtt.device.Dimmer as Dimmer import insteon_mqtt.device.FanLinc as FanLinc import insteon_mqtt.device.KeypadLinc as KeypadLinc +import insteon_mqtt.device.KeypadLincDimmer as KeypadLincDimmer import insteon_mqtt.device.Remote as Remote @@ -364,8 +365,8 @@ def test_FanLinc_scenes_different_ramp_rates(self): def test_KeypadLinc_scenes_same_ramp_rate(self): modem = MockModem() - keypadlinc = KeypadLinc(modem.protocol, modem, Address("11.22.33"), - "KeypadLinc") + keypadlinc = KeypadLincDimmer(modem.protocol, modem, + Address("11.22.33"), "KeypadLinc") modem.devices[str(keypadlinc.addr)] = keypadlinc device = modem.find(Address("aa.bb.cc")) modem.devices[device.label] = device @@ -575,8 +576,8 @@ def test_mini_remote_button_config_with_data3(self): def test_foreign_hub_keypad_button_backlights_scene(self): modem = MockModem() - keypad = KeypadLinc(modem.protocol, modem, Address("11.22.33"), - "Keypad") + keypad = KeypadLincDimmer(modem.protocol, modem, Address("11.22.33"), + "Keypad") modem.devices[str(keypad.addr)] = keypad device = modem.find(Address("aa.bb.cc")) modem.devices[device.label] = device diff --git a/tests/util/helpers/main.py b/tests/util/helpers/main.py index 9500aec8..d16c85fb 100644 --- a/tests/util/helpers/main.py +++ b/tests/util/helpers/main.py @@ -18,6 +18,7 @@ def __init__(self, save_path): self.scenes = [] self.devices = {} self.device_names = {} + self.timed_call = MockTimedCall() def add(self, device): self.devices[device.addr.id] = device @@ -25,7 +26,10 @@ def add(self, device): self.device_names[device.name] = device def find(self, addr): - device = self.devices.get(addr.id, None) + if isinstance(addr, str): + device = self.device_names.get(addr, None) + else: + device = self.devices.get(addr.id, None) return device def remove(self, device): @@ -36,6 +40,8 @@ def remove(self, device): def scene(self, is_on, group, num_retry=3, on_done=None, reason=""): self.scenes.append((is_on, group, reason)) + def clear_db_config(self): + pass #=========================================================================== class MockProtocol: @@ -90,4 +96,10 @@ class MockTimedCall: def __init__(self): pass + def add(self, *args, **kwargs): + pass + + def remove(self, *args, **kwargs): + pass + #===========================================================================