diff --git a/.bumpversion.cfg b/.bumpversion.cfg index fa91d8c0..50177054 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -5,7 +5,7 @@ tag = False [bumpversion:file:setup.py] -[bumpversion:file:insteon_mqtt/__init__.py] +[bumpversion:file:insteon_mqtt/const.py] [bumpversion:file:README.md] search = Version: {current_version} diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index db56379a..1b65bb9c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - dev + - Discovery jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e60498..a52749ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Revision Change History +## [0.9.0] + +### Discovery Platform! + +- Added support for the [HomeAssistant Discovery Protocol](https://www.home-assistant.io/docs/mqtt/discovery/). + This allows users to avoid defining insteon entities in their + HomeAssistant configuration. + + __NOTE__ Migrating from a functional installation to the Discovery Platform + will be __tedious__ and may not add much functionality. The next minor + release of InsteonMQTT should improve this slightly, as it will apply + default config settings, so you won't have to do as much copy and pasting. + It may be worth waiting until then if you are interested in migrating. + +### Additions + +- Added `join_all` and `pair_all` commands to improve the experience when + initializing a network or setting up many devices. Note, the + _skip battery devices_ option was __removed__ from the `refresh_all` and + `get_engine_all` commands. ([PR 389][P389]) +- Add a `{{timestamp}}` variable to templating. Will output the current + timestamp. Useful for testing whether a received state change was triggered + by a change, or is simply a retained mqtt message that has been resent + because of a restart. ([PR 393][P393]) + +### Fixes + +- Fix cyclic import error([PR 388][P388]) +- Save DB Delta on Refresh([PR 392][P392]) + ## [0.8.3] ### Breaking Change in HomeAssistant @@ -7,7 +37,7 @@ - HomeAssistant version 2021.4.0 now only supports percentages for fan speeds. This means any fan entities in HomeAssistant that were configured to use "low", "medium", and "high" for the fan speed will no longer work. - See [config-example.yaml](https://github.com/TD22057/insteon-mqtt/blob/master/config-example.yaml) + See [config-example.yaml](https://github.com/TD22057/insteon-mqtt/blob/master/config-example.yaml) under the `mqtt -> fan` section for a suggest configuration in HomeAssistant. Thanks @Juggler00 ([PR 378][P378]) @@ -16,7 +46,7 @@ - Adds and advanced option for setting the minimum hop count for specific devices. See [min_hops][config_extra] ([PR 376][P376]) -- Adds a bunch of documentation including [Templating Guide][TGuide] +- Adds a bunch of documentation including [Templating Guide][TGuide] and [Debugging][DbgGuide] as well as additions to other existing pages. Also reorganized the documentation to try and make it easier to find. @@ -124,7 +154,7 @@ the program. [Hub Instructions](https://github.com/TD22057/insteon-mqtt/blob/dev/docs/hub.md) ([PR 201][P201]) -- Significantly improved Home Assistant Add-on installation! +- Significantly improved Home Assistant Add-on installation! [Instructions](docs/HA_Addon_Instructions.md) Includes update notifications, nicer icons, and better integration into Home Assistant. [PR 290][P290] @@ -219,7 +249,7 @@ the program. (thanks @tstabrawa)([PR 234][P234]) - Database delta is updated on database writes. This eliminates a number of - unnecessary refresh requirements, particularly around pairing. + unnecessary refresh requirements, particularly around pairing. ([PR 248][P248]) - Minor fix to the calculation of hops on resent messages. ([PR 259][P259]) @@ -677,3 +707,8 @@ will add new features. [config_extra]: https://github.com/TD22057/insteon-mqtt/blob/master/docs/config_extra.md [P376]: https://github.com/TD22057/insteon-mqtt/pull/376 [P378]: https://github.com/TD22057/insteon-mqtt/pull/378 +[P388]: https://github.com/TD22057/insteon-mqtt/pull/388 +[P389]: https://github.com/TD22057/insteon-mqtt/pull/389 +[P390]: https://github.com/TD22057/insteon-mqtt/pull/390 +[P392]: https://github.com/TD22057/insteon-mqtt/pull/392 +[P393]: https://github.com/TD22057/insteon-mqtt/pull/393 diff --git a/config-example.yaml b/config-example.yaml index 3e2e1864..13f5a227 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -189,6 +189,60 @@ mqtt: # send these low level commands. cmd_topic: 'insteon/command' + ### Discovery Settings + # + # Home Assistant implements mqtt device discovery as outlined at: + # https://www.home-assistant.io/docs/mqtt/discovery + # if discover_topic_base is defined, devices (as defined in config.yaml) + # announce themselves to Home Assistant. Announcing occurs once + # upon startup of insteon-mqtt and whenever HomeAssistant restart. + # + # The details of discovery_entities and how to define your own discovery + # templates can be found here: + # https://github.com/TD22057/insteon-mqtt/blob/master/docs/discovery.md + # + # Any additional variables that a specific device may offer are documented + # in the comments below under that device class. + # + # TO ENABLE THE DISCOVERY PLATFORM: Set the following to true + enable_discovery: false + + # This defines the base topic for publishing discovery messages, likely + # does not need to be changed unless you changed your setting in + # HomeAssistant + discovery_topic_base: 'homeassistant' + + # The mqtt topic to monitor for HomeAssistant status. This likely doesn't + # need to be changed unless you have altered it. When the message 'online' + # is received on this topic, InsteonMQTT will publish the discovery + # entities. See https://www.home-assistant.io/docs/mqtt/birth_will/ + discovery_ha_status: 'homeassistant/status' + + # This is a variable that is available for use in all templates, as + # {{device_info_template}}. It is envisioned that it would be used to set + # the device map information, see e.g. + # https://www.home-assistant.io/integrations/switch.mqtt/#device + # While the identifiers or (ids) section is listed as optional, it has been + # my experience that it is required. + # This device section describes the parent device that all sub-entities are + # grouped under + device_info_template: |- + { + "ids": "{{address}}", + "mf": "Insteon", + "mdl": "{%- if model_number != 'Unknown' -%} + {{model_number}} - {{model_description}} + {%- elif dev_cat_name != 'Unknown' -%} + {{dev_cat_name}} - 0x{{'%0x' % sub_cat|int }} + {%- elif dev_cat == 0 and sub_cat == 0 -%} + No Info + {%- else -%} + 0x{{'%0x' % dev_cat|int }} - 0x{{'%0x' % sub_cat|int }} + {%- endif -%}", + "sw": "0x{{'%0x' % firmware|int }} - {{engine}}", + "name": "{{name_user_case}}", + "via_device": "{{modem_addr}}" + } # Trigger modem virtual scenes. Modem scenes are where the modem is a # controller and emits a scene broadcast with the specified group number. @@ -220,10 +274,44 @@ mqtt: # json = the input payload converted to json. Use json.VAR to extract # a variable from a json payload. scene_topic: 'insteon/modem/scene' - scene_payload: '{ "cmd" : "{{json.cmd.lower()}}", + scene_payload: '{ "cmd" : "{{json.state.lower()}}", "group" : {{json.group}} }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + # + # The modem has 253 possible scenes from scene 2-254. The modem ONLY + # ACCEPTS A SINGLE entity template. This template will be used for each of + # the scenes. + # + # This is the only place where you can set the template for the modem, it + # does NOT use the `discovery_class` extra configuration setting. + # + # Special variables: + # {{scene}} - will be replaced with the scene number of the scene. + # {{scene_name}} - will be replaced with the name of the scene as defined + # in a scenes.yaml file if used. + # {{scene_topic}} - is the modem scene topic + # + # Only scenes that appear in the GroupMap list when printing the modem db + # will be included. These are the only scenes for which the modem is + # listed as a controller. Run `refresh modem` if the entities list + # appears incomplete to you. + discovery_entities: + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_{{scene}}", + "name": "{%- if scene_name != "" -%} + {{scene_name}} + {%- else -%} + Modem Scene {{scene}} + {%- endif -%}", + "cmd_t": "{{scene_topic}}", + "device": {{device_info_template}}, + "payload_on": "{\"cmd\": \"on\", \"group\": \"{{scene}}\"}", + "payload_off": "{\"cmd\": \"off\", \"group\": \"{{scene}}\"}" + } # IMPORTANT: all devices must have the pair() command run one time to make # sure that the all the necessary controller/responder links are defined @@ -245,6 +333,7 @@ mqtt: # the device state changes for any reason. Available variables for # templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # on = 0/1 # on_str = 'off'/'on' @@ -287,6 +376,18 @@ mqtt: {% endif %} }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_switch", + "name": "{{name_user_case}}", + "cmd_t": "{{on_off_topic}}", + "stat_t": "{{state_topic}}", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Dimmers #------------------------------------------------------------------------ @@ -308,6 +409,7 @@ mqtt: # whenever the device state changes for any reason. Available # variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # on = 0/1 # on_str = 'off'/'on' @@ -338,6 +440,7 @@ mqtt: # ["fast" : 1/0], ["instant" : 1/0], ["reason" : "..."] } # Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # value = the input payload # json = the input payload converted to json. Use json.VAR to extract @@ -360,6 +463,7 @@ mqtt: # LEVEL = 0->255 dimmer level # Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # value = the input payload # json = the input payload converted to json. Use json.VAR to extract @@ -383,15 +487,32 @@ mqtt: # in the same way clicking the button would. The inputs are the same as # those for the on_off topic and payload. scene_topic: 'insteon/{{address}}/scene' - scene_payload: '{ "cmd" : "{{json.cmd.lower()}}" + scene_payload: '{ "cmd" : "{{json.state.lower()}}" {% if json.brightness is defined %} , "level" : {{json.brightness}} {% endif %} }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'light' + config: |- + { + "uniq_id": "{{address}}_light", + "name": "{{name_user_case}}", + "cmd_t": "{{level_topic}}", + "stat_t": "{{state_topic}}", + "brightness": true, + "schema": "json", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Battery powered sensors - # door sensors, hidden door sensors, window sensors + # door sensors, window sensors + # + # Other devices (motion, leak, hidden_door, and remote) inherit the + # topics defined here and then add a few more. #------------------------------------------------------------------------ # In Home Assistant use MQTT binary sensor with a configuration like: @@ -408,6 +529,7 @@ mqtt: # whenever the device state changes for any reason. Available # variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # on = 0/1 # on_str = 'off'/'on' @@ -418,6 +540,7 @@ mqtt: # whenever the device detects a low battery. Available variables # for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # is_low = 0/1 # is_low_str = 'off'/'on' @@ -427,6 +550,7 @@ mqtt: # Output heartbeat topic and payload. This message is sent # every 24 hours. Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # is_heartbeat = 0/1 # heartbeat_time = UNIX time float of the last heartbeat @@ -435,6 +559,39 @@ mqtt: heartbeat_topic: 'insteon/{{address}}/heartbeat' heartbeat_payload: '{{heartbeat_time}}' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + # this only applies to devices defined as battery_sensor. Devices that + # extend this class (motion, hidden_door, leak, and remote) all have their + # own discovery_entities + discovery_entities: + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_door", + "name": "{{name_user_case}} door", + "stat_t": "{{state_topic}}", + "device_class": "door", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_battery", + "name": "{{name_user_case}} battery", + "stat_t": "{{low_battery_topic}}", + "device_class": "battery", + "device": {{device_info_template}} + } + - component: 'sensor' + config: |- + { + "uniq_id": "{{address}}_heartbeat", + "name": "{{name_user_case}} heartbeat", + "stat_t": "{{heartbeat_topic}}", + "device_class": "timestamp", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Motion sensors #------------------------------------------------------------------------ @@ -454,6 +611,7 @@ mqtt: # whenever the device light sensor detects dawn or dusk changes. # Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # is_dawn = 0/1 # is_dawn_str = 'off'/'on' @@ -463,6 +621,36 @@ mqtt: dawn_dusk_topic: 'insteon/{{address}}/dawn' dawn_dusk_payload: '{{is_dawn_str.upper()}}' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_motion", + "name": "{{name_user_case}} motion", + "stat_t": "{{state_topic}}", + "device_class": "motion", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_battery", + "name": "{{name_user_case}} battery", + "stat_t": "{{low_battery_topic}}", + "device_class": "battery", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_dusk", + "name": "{{name_user_case}} dusk", + "stat_t": "{{dawn_dusk_topic}}", + "device_class": "light", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Hidden Door Sensors #------------------------------------------------------------------------ @@ -471,24 +659,64 @@ mqtt: # inputs from battery_sensor and add battery voltage which is configured # here. # - # To register the hidden door sensor in Home Assistant use MQTT binary + # To register the battery voltage sensor in Home Assistant use MQTT # sensor with a configuration like: - # binary_sensor: + # sensor: # - platform: mqtt - # state_topic: 'insteon/aa.bb.cc//battery_voltage' - #TODO device_class: 'light' + # state_topic: 'insteon/aa.bb.cc/battery_voltage' + # device_class: 'voltage' 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' + # timestamp = the current timestamp # name = 'device name' # batt_volt = raw insteon voltage level battery_voltage_topic: 'insteon/{{address}}/battery_voltage' battery_voltage_payload: '{"voltage" : {{batt_volt}}}' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_door", + "name": "{{name_user_case}} door", + "stat_t": "{{state_topic}}", + "device_class": "door", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_battery", + "name": "{{name_user_case}} battery", + "stat_t": "{{low_battery_topic}}", + "device_class": "battery", + "device": {{device_info_template}} + } + - component: 'sensor' + config: |- + { + "uniq_id": "{{address}}_heartbeat", + "name": "{{name_user_case}} heartbeat", + "stat_t": "{{heartbeat_topic}}", + "device_class": "timestamp", + "device": {{device_info_template}} + } + - component: 'sensor' + config: |- + { + "uniq_id": "{{address}}_voltage", + "name": "{{name_user_case}} voltage", + "stat_t": "{{battery_voltage_topic}}", + "device_class": "voltage", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Leak sensors #------------------------------------------------------------------------ @@ -515,6 +743,7 @@ mqtt: # whenever the device changes state to wet or dry. # Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # is_wet = 0/1 # is_wet_str = 'off'/'on' @@ -524,6 +753,27 @@ mqtt: wet_dry_topic: 'insteon/{{address}}/wet' wet_dry_payload: '{{is_wet_str.upper()}}' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_wet", + "name": "{{name_user_case}} leak", + "stat_t": "{{wet_dry_topic}}", + "device_class": "moisture", + "device": {{device_info_template}} + } + - component: 'sensor' + config: |- + { + "uniq_id": "{{address}}_heartbeat", + "name": "{{name_user_case}} heartbeat", + "stat_t": "{{heartbeat_topic}}", + "device_class": "timestamp", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Mini remotes #------------------------------------------------------------------------ @@ -537,6 +787,7 @@ mqtt: # Output state change topic and template. This message is sent # whenever a button is pressed. Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # button = 1...n (button number 1-8 depending on configuration) # on = 0/1 @@ -557,6 +808,94 @@ mqtt: #manual_state_topic: 'insteon/{{address}}/manual_state' #manual_state_payload: '{{manual_str.upper()}}' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + # + # The remote class includes additional topics for the state of the various + # buttons, these state topics are: + # state_topic_N + # + # Where N is in the range of [1-8] representing the button group numbers + # 1-8 + # The default setup below includes state topics for all 8 buttons. If you + # have a single button or a 4 button remote, you may want to review the + # instructions at: + # https://github.com/TD22057/insteon-mqtt/blob/master/docs/discovery.md + # for details about how to define a custom class for your devices. + discovery_entities: + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_1", + "name": "{{name_user_case}} btn 1", + "stat_t": "{{state_topic_1}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_2", + "name": "{{name_user_case}} btn 2", + "stat_t": "{{state_topic_2}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_3", + "name": "{{name_user_case}} btn 3", + "stat_t": "{{state_topic_3}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_4", + "name": "{{name_user_case}} btn 4", + "stat_t": "{{state_topic_4}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_5", + "name": "{{name_user_case}} btn 5", + "stat_t": "{{state_topic_5}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_6", + "name": "{{name_user_case}} btn 6", + "stat_t": "{{state_topic_6}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_7", + "name": "{{name_user_case}} btn 7", + "stat_t": "{{state_topic_7}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_8", + "name": "{{name_user_case}} btn 8", + "stat_t": "{{state_topic_8}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_battery", + "name": "{{name_user_case}} battery", + "stat_t": "{{low_battery_topic}}", + "device_class": "battery", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Smoke Bridge #------------------------------------------------------------------------ @@ -585,6 +924,7 @@ mqtt: # whenever the device state changes for any reason. Available # variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # on = 0/1 # on_str = 'off'/'on' @@ -601,6 +941,45 @@ mqtt: error_topic: 'insteon/{{address}}/error' error_payload: '{{on_str.upper()}}' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_smoke", + "name": "{{name_user_case}} smoke", + "stat_t": "{{smoke_topic}}", + "device_class": "smoke", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_battery", + "name": "{{name_user_case}} battery", + "stat_t": "{{battery_topic}}", + "device_class": "battery", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_co", + "name": "{{name_user_case}} co", + "stat_t": "{{co_topic}}", + "device_class": "gas", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_error", + "name": "{{name_user_case}} error", + "stat_t": "{{error_topic}}", + "device_class": "problem", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Thermostat #------------------------------------------------------------------------ @@ -629,6 +1008,7 @@ mqtt: # Output state change topic and payload. Available variables for # templating in all cases are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # The following specific variables only apply to the topics listed # directly below @@ -643,10 +1023,10 @@ mqtt: # fan_mode = "on", "off" # is_fan_on = 0/1 fan_state_topic: 'insteon/{{address}}/fan_state' - fan_state_payload: '{{fan_mode.upper()}}' + fan_state_payload: '{{fan_mode.lower()}}' # mode = 'off', 'auto', 'heat', 'cool', 'program' mode_state_topic: 'insteon/{{address}}/mode_state' - mode_state_payload: '{{mode.upper()}}' + mode_state_payload: '{{mode.lower()}}' # humid = humidity percentage humid_state_topic: 'insteon/{{address}}/humid_state' humid_state_payload: '{{humid}}' @@ -654,7 +1034,7 @@ mqtt: # is_heating = 0/1 # is_cooling = 0/1 status_state_topic: 'insteon/{{address}}/status_state' - status_state_payload: '{{status.upper()}}' + status_state_payload: '{{status.lower()}}' # Caution, there is no push update for the hold or energy state. ie, if # you press hold on the physical device, you will not get any notice of # this unless you run get_status(). There is also no way to programatically @@ -662,16 +1042,17 @@ mqtt: # hold_str = 'off', 'temp' # is_hold = 0/1 hold_state_topic: 'insteon/{{address}}/hold_state' - hold_state_payload: '{{hold_str.upper()}}' + hold_state_payload: '{{hold_str.lower()}}' # See caution in hold state above # energy_str = 'off', 'on' # is_energy = 0/1 energy_state_topic: 'insteon/{{address}}/energ_state' - energy_state_payload: '{{energy_str.upper()}}' + energy_state_payload: '{{energy_str.lower()}}' # Command Topics # Available variables for templating all of these commands are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # value = the input payload # json = the input payload converted to json. Use json.VAR to extract @@ -695,6 +1076,34 @@ mqtt: cool_sp_command_topic: 'insteon/{{address}}/cool_sp_command' cool_sp_payload: '{ "temp_f" : {{value}} }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'climate' + config: |- + { + "uniq_id": "{{address}}_thermo", + "name": "{{name_user_case}} thermo", + "act_t": "{{status_state_topic}}", + "curr_temp_t": "{{ambient_temp_topic}}", + "curr_temp_tpl": "{% raw %}{{value_json.temp_f}}{% endraw %}", + "device": {{device_info_template}}, + "fan_mode_cmd_t": "{{fan_command_topic}}", + "fan_mode_stat_t": "{{fan_state_topic}}", + "fan_modes": ["auto", "on"], + "hold_stat_t": "{{hold_state_topic}}", + "mode_cmd_t": "{{mode_command_topic}}", + "mode_stat_t": "{{mode_state_topic}}", + "modes": ["off", "cool", "heat", "auto"], + "precision": 1.0, + "temp_hi_cmd_t": "{{cool_sp_command_topic}}", + "temp_hi_stat_t": "{{cool_sp_state_topic}}", + "temp_hi_stat_tpl": "{% raw %}{{value_json.temp_f}}{% endraw %}", + "temp_lo_cmd_t": "{{heat_sp_command_topic}}", + "temp_lo_stat_t": "{{heat_sp_state_topic}}", + "temp_lo_stat_tpl": "{% raw %}{{value_json.temp_f}}{% endraw %}", + "temperature_unit": "F" + } + #------------------------------------------------------------------------ # Fan Linc #------------------------------------------------------------------------ @@ -725,7 +1134,7 @@ mqtt: # {% elif value < 75 %} # medium # {% else %} - # high + # high # {% endif %} # percentage_state_topic: 'insteon/aa.bb.cc/fan/speed/state' # percentage_value_template: > @@ -738,12 +1147,16 @@ mqtt: # {% else %} # 0 # {% endif %} + # preset_mode_state_topic: "insteon/aa.bb.cc/fan/speed/state" + # preset_mode_command_topic: "insteon/aa.bb.cc/fan/speed/set" + # preset_modes: ["off", "low", "medium", "high"] fan_linc: # Output fan state change topic and payload. This message is sent # whenever the fan state changes for any reason. Available # variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # on = 0/1 # on_str = 'off'/'on' @@ -760,6 +1173,7 @@ mqtt: # { "cmd" : "on"/"off", ["reason" : "..."] } # Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # value = the input payload # json = the input payload converted to json. Use json.VAR to extract @@ -783,6 +1197,7 @@ mqtt: # or = "off"/"low"/"medium"/"high" # Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # value = the input payload # json = the input payload converted to json. Use json.VAR to extract @@ -790,6 +1205,35 @@ mqtt: fan_speed_set_topic: 'insteon/{{address}}/fan/speed/set' fan_speed_set_payload: '{ "cmd" : "{{value.lower()}}" }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'fan' + config: |- + { + "uniq_id": "{{address}}_fan", + "name": "{{name_user_case}} fan", + "device": {{device_info_template}}, + "cmd_t": "{{fan_on_off_topic}}", + "pct_cmd_t": "{{fan_speed_set_topic}}", + "pct_cmd_tpl": "{% raw %}{% if value < 10 %}off{% elif value < 40 %}low{% elif value < 75 %}medium{% else %}high{% endif %}{% endraw %}", + "pct_stat_t": "{{fan_speed_topic}}", + "pct_val_tpl": "{% raw %}{% if value == 'low' %}33{% elif value == 'medium' %}67{% elif value == 'high' %}100{% else %}0{% endif %}{% endraw %}", + "pr_mode_stat_t": "{{fan_speed_topic}}", + "pr_mode_cmd_t": "{{fan_speed_set_topic}}", + "pr_modes": ["off", "low", "medium", "high"] + } + - component: 'light' + config: |- + { + "uniq_id": "{{address}}_light", + "name": "{{name_user_case}}", + "cmd_t": "{{level_topic}}", + "stat_t": "{{state_topic}}", + "brightness": true, + "schema": "json", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # Keypad Linc #------------------------------------------------------------------------ @@ -833,6 +1277,7 @@ mqtt: # whenever one of the on/off buttons is pressed. It will not be sent for # button 1 if it's a dimmer. Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # button = 1-8 # on = 0/1 @@ -848,6 +1293,7 @@ mqtt: # message is sent whenever the device dimmer state changes for any # reason. Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # on = 0/1 # on_str = 'off'/'on' @@ -899,6 +1345,7 @@ mqtt: # LEVEL = 0->255 dimmer level # Available variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # value = the input payload # json = the input payload converted to json. Use json.VAR to extract @@ -931,6 +1378,117 @@ mqtt: {% endif %} }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + # + # This class includes an additional variable `is_dimmable` which is set + # to True if btn 1 on the device is dimmable otherwise it returns False + # + # The keypad_linc class includes additional topics for the various buttons, + # these topics are: + # btn_state_topic_N + # btn_on_off_topic_N + # btn_scene_topic_N + # + # Where N is in the range of [1-9] representing the button group numbers + # 1-9 + # The default setup below includes topics for all 8 buttons plus the + # the detached load setup. If you have a six button keypad_linc, you may + # want to review the instructions at: + # https://github.com/TD22057/insteon-mqtt/blob/master/docs/discovery.md + # for details about how to define a custom class for your devices. + discovery_entities: + - component: 'light' + config: |- + { + "uniq_id": "{{address}}_1", + "name": "{{name_user_case}} btn 1", + "device": {{device_info_template}}, + "brightness": {{is_dimmable|lower()}}, + "cmd_t": "{%- if is_dimmable -%} + {{dimmer_level_topic}} + {%- else -%} + {{btn_on_off_topic_1}} + {%- endif -%}", + "schema": "json", + "stat_t": "{%- if is_dimmable -%} + {{dimmer_state_topic}} + {%- else -%} + {{btn_state_topic_1}} + {%- endif -%}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_2", + "name": "{{name_user_case}} btn 2", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_2}}", + "stat_t": "{{btn_state_topic_2}}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_3", + "name": "{{name_user_case}} btn 3", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_3}}", + "stat_t": "{{btn_state_topic_3}}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_4", + "name": "{{name_user_case}} btn 4", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_4}}", + "stat_t": "{{btn_state_topic_4}}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_5", + "name": "{{name_user_case}} btn 5", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_5}}", + "stat_t": "{{btn_state_topic_5}}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_6", + "name": "{{name_user_case}} btn 6", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_6}}", + "stat_t": "{{btn_state_topic_6}}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_7", + "name": "{{name_user_case}} btn 7", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_7}}", + "stat_t": "{{btn_state_topic_7}}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_8", + "name": "{{name_user_case}} btn 8", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_8}}", + "stat_t": "{{btn_state_topic_8}}" + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_9", + "name": "{{name_user_case}} btn 9", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_9}}", + "stat_t": "{{btn_state_topic_9}}" + } + #------------------------------------------------------------------------ # IO Linc relay controllers #------------------------------------------------------------------------ @@ -965,33 +1523,36 @@ mqtt: # the device sensor or device relay state changes. Available variables for # templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # sensor_on = 0/1 # relay_on = 0/1 # sensor_on_str = 'off'/'on' # relay_on_str = 'off'/'on' state_topic: 'insteon/{{address}}/state' - state_payload: '{ "sensor" : "{{sensor_on_str.upper()}}", "relay" : {{relay_on_str.upper()}} }' + state_payload: '{ "sensor" : "{{sensor_on_str.lower()}}", "relay" : {{relay_on_str.lower()}} }' # Output relay state change topic and template. This message is sent # whenever the device relay state changes. Available variables for # templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # relay_on = 0/1 # relay_on_str = 'off'/'on' relay_state_topic: 'insteon/{{address}}/relay' - relay_state_payload: '{{relay_on_str.upper()}}' + relay_state_payload: '{{relay_on_str.lower()}}' # Output sensor state change topic and template. This message is sent # whenever the device sensor state changes. Available variables for # templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # sensor_on = 0/1 # sensor_on = 'off'/'on' sensor_state_topic: 'insteon/{{address}}/sensor' - sensor_state_payload: '{{sensor_on_str.upper()}}' + sensor_state_payload: '{{sensor_on_str.lower()}}' # Input on/off command. This forces the relay on/off and ignores the # momentary-A,B,C setting. Use this to force the relay to respond. @@ -1006,6 +1567,26 @@ mqtt: on_off_topic: 'insteon/{{address}}/set' on_off_payload: '{ "cmd" : "{{value.lower()}}" }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + discovery_entities: + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_relay", + "name": "{{name_user_case}} relay", + "cmd_t": "{{on_off_topic}}", + "stat_t": "{{relay_state_topic}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_sensor", + "name": "{{name_user_case}} sensor", + "stat_t": "{{sensor_state_topic}}", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # On/off outlets #------------------------------------------------------------------------ @@ -1027,6 +1608,7 @@ mqtt: # whenever the device state changes for any reason. Available # variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # button = 1 (top outlet) or 2 (bottom outlet) # on = 0/1 @@ -1052,6 +1634,39 @@ mqtt: on_off_topic: 'insteon/{{address}}/set/{{button}}' on_off_payload: '{ "cmd" : "{{value.lower()}}" }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + # + # The outlet class includes additional topics for the two outlets, + # these topics are: + # btn_state_topic_N + # btn_on_off_topic_N + # + # Where N is in the range of [1-2] representing the outlet group numbers + # 1-2 + # The default setup below includes topics for both outlets. If you have a + # single outlet model, you may want to review the instructions at: + # https://github.com/TD22057/insteon-mqtt/blob/master/docs/discovery.md + # for details about how to define a custom class for your devices. + discovery_entities: + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_1", + "name": "{{name_user_case}} top", + "cmd_t": "{{on_off_topic_1}}", + "stat_t": "{{state_topic_1}}", + "device": {{device_info_template}} + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_2", + "name": "{{name_user_case}} bottom", + "cmd_t": "{{on_off_topic_2}}", + "stat_t": "{{state_topic_2}}", + "device": {{device_info_template}} + } + #------------------------------------------------------------------------ # EZIO4O 4 relay output module #------------------------------------------------------------------------ @@ -1072,6 +1687,7 @@ mqtt: # whenever the device state changes for any reason. Available # variables for templating are: # address = 'aa.bb.cc' + # timestamp = the current timestamp # name = 'device name' # button = 1 to 4 (relay number) # on = 0/1 @@ -1097,4 +1713,55 @@ mqtt: on_off_topic: "insteon/{{address}}/set/{{button}}" on_off_payload: '{ "cmd" : "{{value.lower()}}" }' + # Discovery Entities - Used as part of HomeAssistant MQTT Discovery + # + # The ezio4 class includes additional topics for the various relays, + # these topics are: + # btn_state_topic_N + # btn_on_off_topic_N + # + # Where N is in the range of [1-4] representing the relay group numbers + # 1-4 + # The default setup below includes topics for all relays. You may want to + # review the instructions at: + # https://github.com/TD22057/insteon-mqtt/blob/master/docs/discovery.md + # for details about how to define a custom class for your devices. + discovery_entities: + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_1", + "name": "{{name_user_case}} relay 1", + "cmd_t": "{{on_off_topic_1}}", + "stat_t": "{{state_topic_1}}", + "device": {{device_info_template}} + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_2", + "name": "{{name_user_case}} relay 2", + "cmd_t": "{{on_off_topic_2}}", + "stat_t": "{{state_topic_2}}", + "device": {{device_info_template}} + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_3", + "name": "{{name_user_case}} relay 3", + "cmd_t": "{{on_off_topic_3}}", + "stat_t": "{{state_topic_3}}", + "device": {{device_info_template}} + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_4", + "name": "{{name_user_case}} relay 4", + "cmd_t": "{{on_off_topic_4}}", + "stat_t": "{{state_topic_4}}", + "device": {{device_info_template}} + } + #---------------------------------------------------------------- diff --git a/docs/config_extra.md b/docs/config_extra.md index 09ed7324..12fce754 100644 --- a/docs/config_extra.md +++ b/docs/config_extra.md @@ -19,6 +19,15 @@ Some device types have additional settings. > No such settings yet. +## Settings + +### `discovery_class` - Discovery Template Class +This key can be used to define a custom discovery template for this device. +The value of this setting should be a subkey under the `mqtt` key in the yaml +config file. This subkey must contain a `discovery_entities` list of each of +the discovery entities. For more details and examples see +[Discovery](https://github.com/TD22057/insteon-mqtt/blob/master/docs/discovery.md). + ## Advanced Settings These settings can be used with all device types, but should be considered advanced. These settings may cause undesirable effects. diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 00000000..b1f67b7b --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,485 @@ +# MQTT Discovery Platform + +HomeAssistant allows for InsteonMQTT to define entities using a discovery +protocol. This means, that for general installations, a user need only setup +InsteonMQTT following the +[Configuration Instructions](https://github.com/TD22057/insteon-mqtt/blob/master/docs/configuration.md) +and then follow the brief enabling instructions below to get Insteon working +in HomeAssistant. + + + +- [MQTT Discovery Platform](#mqtt-discovery-platform) + - [Enabling the Discovery Platform](#enabling-the-discovery-platform) + - [Customization](#customization) + - [Altering entities in HomeAssistant](#altering-entities-in-homeassistant) + - [Disabling Entities in HomeAssistant](#disabling-entities-in-homeassistant) + - [Deleting Entities in HomeAssistant](#deleting-entities-in-homeassistant) + - [Writing your own templates](#writing-your-own-templates) + - [Default Device Templates](#default-device-templates) + - [Using a Custom Device Template](#using-a-custom-device-template) + - [Writing a `discovery_entities` Template](#writing-a-discovery_entities-template) + - [JSON Dangers](#json-dangers) + - [Passing Jinja Templates as Values](#passing-jinja-templates-as-values) + - [Example `discovery_entities` templates](#example-discovery_entities-templates) + - [The Special `device_info_template` Variable](#the-special-device_info_template-variable) + - [Sample Templates for Custom Discovery Classes](#sample-templates-for-custom-discovery-classes) + - [Single Button Remote](#single-button-remote) + - [Six Button Keypadlinc](#six-button-keypadlinc) + - [Setting Switches as Lights](#setting-switches-as-lights) + + + +## Enabling the Discovery Platform + +Enabling the discovery platform for new installations is very easy. All you +need to do is set the following configuration setting: +to true: + +```YAML +mqtt: + enable_discovery: true +``` + +The config-example.yaml file that ships with InsteonMQTT contains the initial +templates for all Insteon devices. + +> __If you installed InsteonMQTT starting with version 0.8.3 or earlier__, you will +need to read the [Migrating to Discovery](migrating.md) page for instructions on how to +incorporate the changes to the `config.yaml` file into your configuration. + +## Customization + +If the default entities defined by InsteonMQTT do not suit your needs, you may +be able to alter the entities within HomeAssistant. + +### Altering entities in HomeAssistant + +To do this, go to +`Configuration -> Integrations` find the MQTT integration and click on the +entities link. + +This page will contain a list of all of the defined entities. Find the one you +wish to alter and click on it. This settings page allows you to change the +name (only used in the UI) of the entity, the icon used in the UI, and the +entity ID that is used when referencing the entity in automations and in the +frontend. Under advanced settings, you can also change the area of the Device. +Click update to save your changes. + +### Disabling Entities in HomeAssistant + +It may also be the case that InsteonMQTT has defined a number of entities that +you do not need. For example, your keypadlinc may only have 6 buttons but 9 +are defined. + +To remove the extra buttons, go to +`Configuration -> Integrations` find the MQTT integration and click on the +entities link. This page will contain a list of all of the defined entities. +Find the one you wish to disable and click on it. + +To remove the extra buttons, simply toggle tne `enable entity` setting. The +device will now no longer be listed in the UI, and will not show up in the +logbook or history. + +### Deleting Entities in HomeAssistant + +If you remove a device from your insteon network, or in some cases change how +it is defined, you will end up with old entities in HomeAssistant. To remove +an abandoned entity, make sure you remove it from the `devices` section of the +InsteonMQTT `yaml` config file. Then restart InsteonMQTT. You then need to +restart HomeAssistant. + +Then go to, `Configuration -> Integrations` find the MQTT integration and +click on the entities link. This page will contain a list of all of the +defined entities. Find the one you wish to delete and click on it. Abandoned +devices are easy to find because of the red icon on the right side. + +Inside, the settings page, click the Delete button. If the delete button is +disabled, then you have not 1) removed the device from your InsteonMQTT config, +2) restarted InsteonMQTT, OR 3) restared HomeAssistant. + +## Writing your own templates + +To understand how HomeAssistant discovery works, read more about the +[HomeAssistant Discovery Protocol](https://www.home-assistant.io/docs/mqtt/discovery/). + +Tweaking, editing, or adding to the default +configuration and can be done using [Jinja templates](https://github.com/TD22057/insteon-mqtt/blob/master/docs/Templating.md). + +### Default Device Templates + +Discovery Device Templates are contained in your `yaml` config file. They are +defined using the `discovery_entities` key. By +default, a device will look to its corresponding subkey under the `mqtt` key. +So for example, a dimmer device will by default look to the `dimmer` subkey +under the `mqtt` key: + +```YAML +insteon: + device: + dimmer: + - aa.bb.cc: my_dimmer + +mqtt: + dimmer: + discovery_entities: + # This is where device aa.bb.cc will look to find its template by + # default +``` + +### Using a Custom Device Template + +Each device can also define a distinct template for its discovery entities. +This is done using [Device Specific Configuration](https://github.com/TD22057/insteon-mqtt/blob/master/docs/config_extra.md). +Specifically, using the `discovery_class` key. So you can do the following: +```yaml +insteon: + device: + dimmer: + - dd.ee.ff: my_dimmer + discovery_class: my_discovery_class # < Note the lack of hyphen + +mqtt: + my_discovery_class: # < Note the class name + discovery_entities: + # This is where device dd.ee.ff will look to find its template by + # default +``` + +This class can be reused by any number of devices. Any device that uses the +entry `discovery_class: my_discovery_class` will look to this class. + +### Writing a `discovery_entities` Template +The `discovery_entities` key should contain a list. Each list entry will +generate an entity in HomeAssistant. Some devices may only have one entity, +other devices may have multiple entities. + +Each entry in `discovery_entities` is an associative array with the __required__ +keys `component` and `config`. +- `component` - (String) One of the supported HomeAssistant MQTT components, +eg. `binary_sensor`, `light`, `switch` +- `config` - (jinja template) The template must produce a __json__ string that is +acceptable to HomeAssistant. The contents of what is required in this json +string are defined by the +[HomeAssistant Discovery Platform](https://www.home-assistant.io/docs/mqtt/discovery/). + +> The `config` json template __must include__ an entry for `unique_id` or +`uniq_id` containing a unique id for this entity. It is __strongly +recommended__ that you use the the device address as part of this unique id. +The recommended format is `{{address}}_suffix` where the suffix is something +that plainly describes the nature of this enity. Devices with only a single +entity do not need a suffix, but it is still good practice to use one. + +The `config` template has a number of variables available to it. For all +devices this includes at minimum the following, devices may also add +additional variables unique to these devices: + +- `name` = (str) device name in lower case +- `address` = (str) hexadecimal address of device as a string +- `name_user_case` = (str) device name in the case entered by +the user +- `engine` = (str) device engine version (e.g. i1, i2, i2cs). Will return +`Unknown` if unknown. +- `model_number` = (str) device model number (e.g. 2476D). Will return +`Unknown` if unknown. +- `model_description` = (str) description (e.g. SwitchLinc Dimmer) Will return +`Unknown` if unknown. +- `firmware` = (int) device firmware version. Will return 0 by default +- `dev_cat` = (int) device category. Will return 0 by default +- `dev_cat_name` = (str) device category name Will return `Unknown` if unknown. +- `sub_cat` = (int) device sub-category. Will return 0 by default +- `modem_addr` = (str) hexadecimal address of modem as a string +- `device_info_template` = (jinja template) a template defined in +config.yaml. _See below_ +- `<>` = (str) topic keys as defined in the config.yaml +file under the _default class_ for this device are available as variables. + +> The `<>` available are __always__ those listed under the default +class for this device. So for a `dimmer` the topics will be gathered from +the `mqtt->dimmer` key. Topics listed under a user defined `discovery_class` +will be ignored. + +Additional variables may be offered by specific devices classes. Those +variables are defined in the `config-example.yaml` file under the relevant +`mqtt` device keys. + +#### JSON Dangers + +> The `config` json template __must generate valid json__. This is a good json +[validator](https://jsonformatter.curiousconcept.com/). + +__Notable Gotchas__ + +1. __Newline Characters__ - JSON strings cannot contain raw newline characters, +they can however be represented by `\n`. Keep in mind that the config template +is first injested from yaml. You can read about +[how yaml handles whitespace](https://yaml-multiline.info/). Second, the +config template is rendered through Jinja. You can read about +[how jinja handles whitespace](https://tedboy.github.io/jinja2/templ6.html). +2. __Trailing Commas__ - JSON cannot include trailing commas. The last item +in a list or the last key:value pair in an object __cannot__ be followed by a +comma. +3. __Single Quotes__ - JSON requires doubles quotes, you __cannot__ use single +quotes to define a string. You can escape double quotes with `\"` + +#### Passing Jinja Templates as Values +HomeAssistant uses jinja templates as well, and in a number of cases entities +have configuration settings that contain a template. If you attempt to enter a +template as a value, it will be rendered by InsteonMQTT, which in this case +would likely result with an empty value. + +To pass an unrendered template on to HomeAssistant __you must escape the +template__. The template can be escaped using the `{% raw %} {{escaped_stuff}} {% endraw %}` +format. For example: + +```yaml +mqtt: + climate: + discovery_entities: + - component: "climate" + config: |- + { + .... # other settings + "temp_lo_stat_tpl": "{% raw %}{{value_json.temp_f}}{% endraw %}", + } +``` + +#### Example `discovery_entities` templates + +```yaml +mqtt: + # Other keys ommitted + dimmer: + # Other keys omitted + discovery_entities: + - component: 'light' + config: |- + { + "uniq_id": "{{address}}_light", + "name": "{{name_user_case}}", + "cmd_t": "{{level_topic}}", + "stat_t": "{{state_topic}}", + "brightness": true, + "schema": "json", + "device": {{device_info_template}} + } +``` + +### The Special `device_info_template` Variable +Inside HomeAssistant each entity config can contain a description about the +device that the entity is contained in. This is mostly a cosmetic feature +that provides some level of topology to HomeAssistant and can allow you to +see all of the entities on a single device. + +This device description configuration is likely going to use an identical +template from one device to the next. To make things easier, the subkey +`device_info_template` can be defined under the `mqtt` key. The contents +of this key should be a template that when rendered produces the device_info +relevant to the majority of your devices. This template can then be inserted +into any of the `discovery_entities` by using the `device_info_template` +variable. + +For example, the following a complex template that produces a nice device +info: +```YAML +mqtt: + # Other keys omitted + device_info_template: |- + { + "ids": "{{address}}", + "mf": "Insteon", + "mdl": "{%- if model_number != 'Unknown' -%} + {{model_number}} - {{model_description}} + {%- elif dev_cat_name != 'Unknown' -%} + {{dev_cat_name}} - 0x{{'%0x' % sub_cat|int }} + {%- elif dev_cat == 0 and sub_cat == 0 -%} + No Info + {%- else -%} + 0x{{'%0x' % dev_cat|int }} - 0x{{'%0x' % sub_cat|int }} + {%- endif -%}", + "sw": "0x{{'%0x' % firmware|int }} - {{engine}}", + "name": "{{name_user_case}}", + "via_device": "{{modem_addr}}" + } +``` + +This when used in a `discovery_entities` template described above will render +as: + +```JSON +{ + "uniq_id": "4f.23.38_light", + "name": "my dimmer", + "cmd_t": "insteon/4f.23.38/level", + "stat_t": "insteon/4f.23.38/state", + "brightness": true, + "schema": "json", + "device": { + "ids": "4f.23.38", + "mf": "Insteon", + "mdl": "2477D - SwitchLinc Dimmer (Dual-Band)", + "sw": "0x45 - i2cs", + "name": "my dimmer", + "via_device": "41.ee.e6" + } +} +``` + +## Sample Templates for Custom Discovery Classes + +### Single Button Remote + +The default remote configuration exposes entities for all eight +buttons. However, if you have a single button remote, you likely +only want to see an entity for that single button. The following +sample configuration settings will enable that: + +```yaml +insteon: + device: + mini_remote1:: + - dd.ee.ff: my_remote + discovery_class: remote_1 # < note no dash at start of line + +mqtt: + remote_1: # < Note the class name + discovery_entities: + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_btn", + "name": "{{name_user_case}} btn", + "stat_t": "{{state_topic_1}}", + "device": {{device_info_template}} + } + - component: 'binary_sensor' + config: |- + { + "uniq_id": "{{address}}_battery", + "name": "{{name_user_case}} battery", + "stat_t": "{{low_battery_topic}}", + "device_class": "battery", + "device": {{device_info_template}} + } + - component: 'sensor' + config: |- + { + "uniq_id": "{{address}}_heartbeat", + "name": "{{name_user_case}} heartbeat", + "stat_t": "{{heartbeat_topic}}", + "device_class": "timestamp", + "device": {{device_info_template}} + } +``` + +### Six Button Keypadlinc + +The default Keypad_linc configuration exposes entities for all eight +buttons. However, if you have a six button keypad_linc, you likely +only want to see entities for those six buttons. The following +sample configuration settings will enable that: + +```yaml +insteon: + device: + keypad_linc:: + - 11.22.33: my_6_button_kpl + discovery_class: kpl_6 # < note no dash at start of line + +mqtt: + kpl_6: # < Note the class name + discovery_entities: + - component: 'light' + config: |- + { + "uniq_id": "{{address}}_1", + "name": "{{name_user_case}} btn 1", + "device": {{device_info_template}}, + "brightness": {{is_dimmable|lower()}}, + "cmd_t": "{%- if is_dimmable -%} + {{dimmer_level_topic}} + {%- else -%} + {{btn_on_off_topic_1}} + {%- endif -%}", + "schema": "json", + "stat_t": "{%- if is_dimmable -%} + {{dimmer_state_topic}} + {%- else -%} + {{btn_state_topic_1}} + {%- endif -%}" + } + - component: 'switch' # No button 2 on 6 button devices + config: |- + { + "uniq_id": "{{address}}_3", + "name": "{{name_user_case}} btn 3", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_3}}", + "stat_t": "{{btn_on_off_topic_3}}", + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_4", + "name": "{{name_user_case}} btn 4", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_4}}", + "stat_t": "{{btn_on_off_topic_4}}", + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_5", + "name": "{{name_user_case}} btn 5", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_5}}", + "stat_t": "{{btn_on_off_topic_5}}", + } + - component: 'switch' + config: |- + { + "uniq_id": "{{address}}_6", + "name": "{{name_user_case}} btn 6", + "device": {{device_info_template}}, + "cmd_t": "{{btn_on_off_topic_6}}", + "stat_t": "{{btn_on_off_topic_6}}", + } + # No buttons 7-9 on 6 button devices +``` + +### Setting Switches as Lights + +Switchlincs are by default defined as `switch` components in HomeAssistant. +However, you may prefer to define them as `light` components without the +dimming feature. This has a few benefits, 1) the component classification may +better match the actual use, 2) you get the nice lightbulb icon automatically, +3) when the entities are linked to devices such as Google Home or Amazon Alexa +HomeAssistant, they will appear within these platforms as lights. + +To do this, define a new custom `discovery_class` as follows: + +```yaml +mqtt: + switch_as_light: + # Maps a switch to a light, which is nicer in HA for actual lights + discovery_entities: + - component: "light" + config: >- + { + "uniq_id": "{{address}}_light", + "name": "{{name_user_case|title}}", + "cmd_t": "{{on_off_topic}}", + "stat_t": "{{state_topic}}", + "brightness": false, + "schema": "json", + "device": {{device_info_template}} + } +``` + +Then for each device just add the discovery class: + +```yaml +devices: + switch: + - aa.bb.cc: My Light + discovery_class: switch_as_light diff --git a/docs/initializing.md b/docs/initializing.md index 6e46dbfc..a8261514 100644 --- a/docs/initializing.md +++ b/docs/initializing.md @@ -8,62 +8,89 @@ All of these functions are idempotent, they can be run multiple times without ca > __Note__ Battery devices (e.g. motion sensors, remotes, etc) are normally sleeping and will not respond to commands sent to them. If the commands below are sent to a battery device, the command will be queued and will attempt to be run the next time the device is awake. For more details, see [battery devices](battery_devices.md) -1. __Join__ - This is necessary to allow the modem to talk to the device. This needs to be done first on any new device or device that has been factory reset. If you are seeing the error `Senders ID not in responders db. Try running 'join' again.` You need to run `join`. - - _Command Line_ - ``` - insteon-mqtt config.yaml join aa.bb.cc - ``` - _MQTT_ - ``` - Topic: /insteon/command/aa.bb.cc - Payload: { "cmd" : "join" } - ``` +1. __Join__ This is necessary to allow the modem to talk to the device. This needs to be done first on any new device or device that has been factory reset. If you are seeing the error `Senders ID not in responders db. Try running 'join' again.` + - To join a __single device__ run `join`. + + _Command Line_ + ``` + insteon-mqtt config.yaml join aa.bb.cc + ``` + _MQTT_ + ``` + Topic: /insteon/command/aa.bb.cc + Payload: { "cmd" : "join" } + ``` + + - To join __all__ devices run `join_all`. This may be necesary when first setting up a network. + + _Command Line_ + ``` + insteon-mqtt config.yaml join-all + ``` + _MQTT_ + ``` + Topic: /insteon/command/modem + Payload: { "cmd" : "join_all" } + ``` 2. __Pair__ - This adds links to the device so that the device knows to notify the modem of state changes. If you do not see any activity in Insteon-MQTT when you manually activate a device, you should try running `pair` again. - _Command Line_ - ``` - insteon-mqtt config.yaml pair aa.bb.cc - ``` - _MQTT_ - ``` - Topic: /insteon/command/aa.bb.cc - Payload: { "cmd" : "pair" } - ``` - -3. __Refresh__ - This downloads the 1) device link database, if necessary; 2) model information, if necessary; 3) the current state (e.g. on/off); and 4) other relevant details from the device. + - To pair a __single device__ run `pair`. + + _Command Line_ + ``` + insteon-mqtt config.yaml pair aa.bb.cc + ``` + _MQTT_ + ``` + Topic: /insteon/command/aa.bb.cc + Payload: { "cmd" : "pair" } + ``` + + - To pair __all__ devices run `pair_all`. This may be necesary when first setting up a network. + + _Command Line_ + ``` + insteon-mqtt config.yaml pair-all + ``` + _MQTT_ + ``` + Topic: /insteon/command/modem + Payload: { "cmd" : "pair_all" } + ``` + +3. __Refresh__ - This downloads the 1) device link database, if necessary; 2) model information, if necessary; 3) the current state (e.g. on/off); and 4) other relevant details from the device. It may take a few seconds per device to complete all of these steps. - `force` - this flag will cause the link database of to be refreshed even if it appears that our cached data is current. >If the device state is updated as a result of a `refresh` command the [reason](reason.md) string will be set to 'refresh' - _Command Line_ - ``` - insteon-mqtt config.yaml refresh aa.bb.cc [--force] - ``` + > If you manually add links to the device (e.g. by using some other device such as an ISY, or by using the set buttons on the device) you will need to run `refresh` again so that Insteon-MQTT can learn about these links. - _MQTT_ - ``` - Topic: /insteon/command/aa.bb.cc - Payload: { "cmd" : "refresh", ["force" : true/false] } - ``` + - To refresh a __single device__ run `refresh`. - > If you manually add links to the device (e.g. by using some other device such as an ISY, or by using the set buttons on the device) you will need to run `refresh` again so that Insteon-MQTT can learn about these links. + _Command Line_ + ``` + insteon-mqtt config.yaml refresh aa.bb.cc [--force] + ``` -4. __Refresh-All__ - This is the same as the `refresh` command but it will be run on all devices configured in your `config.yaml` file. It is useful when first setting up a new network. This may take a while and __battery devices__ (motion sensors, remotes, etc) __will be skipped__ by default. - - `force` - this flag will cause the link database of to be refreshed even if it appears that our cached data is current. - - `battery` - this flag will cause battery devices to be refreshed. If a battery device is not awake, the message will be queued. - - _Command Line_ - ``` - insteon-mqtt config.yaml refresh-all [--force] [--battery] - ``` - - _MQTT_ - ``` - Topic: /insteon/command/modem - Payload: { "cmd" : "refresh_all", ["battery" : true/false, "force" : true/false] } - ``` + _MQTT_ + ``` + Topic: /insteon/command/aa.bb.cc + Payload: { "cmd" : "refresh", ["force" : true/false] } + ``` + + - To refresh __all__ devices run `refresh_all`. This may be necesary when first setting up a network. __This may take a while to complete__ + + _Command Line_ + ``` + insteon-mqtt config.yaml refresh-all [--force] + ``` + + _MQTT_ + ``` + Topic: /insteon/command/modem + Payload: { "cmd" : "refresh_all", ["force" : true/false] } + ``` ## Scene Commands diff --git a/docs/migrating.md b/docs/migrating.md new file mode 100644 index 00000000..702ea283 --- /dev/null +++ b/docs/migrating.md @@ -0,0 +1,86 @@ +# Migrating to Discovery for Installations 0.8.3 and Earlier + +The current design of InsteonMQTT uses a single configuration file. Sadly +this means that when new additions are added to the configuration file, you +need to copy them from the sample configuration file into your own file. + +## Arguments Against Migrating + +Upgrading your config file to use the discovery platform and switching from +yaml defined entities in HomeAssistant o use the discovery platform will +require a little bit +of work. Depending on your installation, this could take __hours of work__. +So please consider whether this is worth it for you. + +### Does Not Offer New InsteonMQTT Functionality + +The discovery platform is a __great feature for new users__. It allows them to +define insteon devices once and get HomeAssistant entities with zero effort. + +However, if you have already put in the work to define your insteon entities +in HomeAssistant there is likely little benefit to abandoning that work. The +discovery platform does not expose any additional features or functionality +that you cannot achieve with standard yaml defined entities. + +### Offers Minor Improvements to HomeAssistant Functionality + +Using the Discovery Platform will give you access to the entity registery +through the `Configuration -> Integrations` page in HomeAssistant. This allows +the user to change items using a graphical user interface, but all of the same +items can be modified using yaml defintions as well. + +### Upgrading Is Currently Time Consuming (Future releases should improve this) + +The next minor release of InsteonMQTT intends to solve issues #383 and #391 +This should decrease the amount of copy and pasting that you have to do. +It is up to you, __but it may be easier to wait for the next minor release__ +before switching to the discovery platform. + +## Arguments for Migrating + +The discovery platform is a clear win for new users. + +For existing users, the only real benefit, is likely to be minor tweaks and +improvements to the HomeAssistant interaction. It is clear, that HomeAssistant +is heading away from the yaml configuration style and towards a more gui based +configuration. + +# How to Migrate to the Discovery Platform + +As noted, this could take some time, it isn't really something that you can +do in steps, so be sure you have enough time set aside. + +1. Make a backup copy of your InsteonMQTT config.yaml file. +2. Make a backup copy of all HomeAssistant configurations that define insteon +entities. +3. Copy the discovery settings in the `mqtt` key from the config-example.yaml +file. Specifically, the `enable_discovery`, `discovery_topic_base`, `discovery_ha_status` and `device_info_template` keys. +4. Under each of the device subkeys (e.g. `modem`, `switch`, `dimmer` ...) copy +the `discovery_entities` from the config-example.yaml into your config file. + +>The above steps can be completed without affecting your installation. The +following steps make changes that must either be completed or reverted to +enable things to work. + +5. Check to see if any of your `*_payload` entries differs from the suggested +entry defined in the config-example.yaml. The best way to do this is using a +diff tool. If they are different, either update your `*_payload` defintion, or +amend the `discovery_entities` as necessary. For example, if your +`state_payload` generates a json payload, the `discovery_entities` needs to be +defined to expect a json payload. +6.Remove or comment out the inston entities in your HomeAssistant +configuration. +7. Restart HomeAssistant (your front end will likely be filled with yellow triangles). +8. Make sure `enable_discovery` is set to `true` in your InsteonMQTT config. +9. Restart InsteonMQTT. +10. Using `Configuration -> Integrations` in HomeAssistant rename and adjust +the entity ID of the discovered insteon entities to match your prior +installation. You can hover over the yellow triangles in your fron end to see +the missing Entity IDs. Once the Entity ID has been fixed, the yellow triangle +will go away. You can also review your old insteon entity defintions one by +one to verify that your entities have been created and are correctly identified. +11. Check the HomeAssistant log and the InsteonMQTT log for any errors. + +> If you make changes to your InsteonMQTT config, you will need to restart +InsteonMQTT for them to take effect. It seems like in some cases, you may +need to restart HomeAssistant for certain changes to take effect. diff --git a/docs/mqtt.md b/docs/mqtt.md index db80f33e..45a30bb6 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -97,6 +97,11 @@ Supported: devices See [initialization](initializing.md) for a discussion of `join`. +### Join all devices + +Supported: modem + +See [initialization](initializing.md) for a discussion of `join-all`. ### Pair a Device to the Modem @@ -104,6 +109,11 @@ Supported: devices See [initialization](initializing.md) for a discussion of `pair`. +### Pair all devices + +Supported: modem + +See [initialization](initializing.md) for a discussion of `pair-all`. ### Sync Device Links @@ -214,11 +224,10 @@ data. Supported: modem This will cause a get_engine command to be sent to each device (i.e. devices -defined in the config file). If the battery flag is false or not present, -battery operated devices will be skipped. The command payload is: +defined in the config file). The command payload is: ``` - { "cmd" : "get_engine_all", ["battery": true/false]} + { "cmd" : "get_engine_all"} ``` ### Add the device as a controller of another device. @@ -725,8 +734,8 @@ speed payload template must convert the input message into the format ``` Here is a sample configuration. HomeAssistant starting in version 2021.4.0 -dropped support for the off/low/medium/high fan speeds. See -[config-example.yaml](https://github.com/TD22057/insteon-mqtt/blob/master/config-example.yaml) +dropped support for the off/low/medium/high fan speeds. See +[config-example.yaml](https://github.com/TD22057/insteon-mqtt/blob/master/config-example.yaml) in the mqtt -> fan section for an example HomeAssistant config that works with InsteonMQTT. diff --git a/insteon_mqtt/Modem.py b/insteon_mqtt/Modem.py index 03e8eb95..8b2b2bcb 100644 --- a/insteon_mqtt/Modem.py +++ b/insteon_mqtt/Modem.py @@ -7,7 +7,7 @@ import os import sys import functools -import insteon_mqtt +from .const import __version__ from .Address import Address from .CommandSeq import CommandSeq from . import config @@ -83,6 +83,8 @@ def __init__(self, protocol, stack, timed_call): 'refresh' : self.refresh, 'refresh_all' : self.refresh_all, 'get_engine_all' : self.get_engine_all, + 'join_all' : self.join_all, + 'pair_all' : self.pair_all, 'get_model' : self.get_model, 'linking' : self.linking, 'scene' : self.scene, @@ -110,6 +112,13 @@ def __init__(self, protocol, stack, timed_call): # to each device. self.protocol.signal_received.connect(self.handle_received) + # For compatibility with devices, this is empty. The Modem does not + # use config_extra settings. + self.config_extra = {} + + # A prettier name for the modem + self.name_user_case = "Modem" + #----------------------------------------------------------------------- def clear_db_config(self): """Clears and initializes the device config database @@ -256,7 +265,7 @@ def version(self, on_done=None): Default Topic: 'insteon/command/modem' Payload: '{"cmd": "version"}' """ - on_done(True, insteon_mqtt.__version__, None) + on_done(True, __version__, None) #----------------------------------------------------------------------- def refresh(self, force=False, on_done=None): @@ -420,7 +429,7 @@ def find(self, addr): return device #----------------------------------------------------------------------- - def refresh_all(self, battery=False, force=False, on_done=None): + def refresh_all(self, force=False, on_done=None): """Refresh all the all link databases. This forces a refresh of the modem and device databases. This can @@ -429,8 +438,6 @@ def refresh_all(self, battery=False, force=False, on_done=None): activity is expected on the network. Args: - battery (bool): If true, will scan battery devices as well, by - default they are skipped. force (bool): Force flag passed to devices. If True, devices will refresh their Insteon db's even if they think the db is up to date. @@ -447,18 +454,13 @@ def refresh_all(self, battery=False, force=False, on_done=None): # Reload all the device databases. for device in self.devices.values(): - if not battery and isinstance(device, (DevClass.BatterySensor, - DevClass.Leak, - DevClass.Remote)): - LOG.ui("Refresh all, skipping battery device %s", device.label) - continue seq.add(device.refresh, force) # Start the command sequence. seq.run() #----------------------------------------------------------------------- - def get_engine_all(self, battery=False, on_done=None): + def get_engine_all(self, on_done=None): """Run Get Engine on all the devices, except Modem Devices are assumed to be i2cs, which all new devices are. If you @@ -467,8 +469,6 @@ def get_engine_all(self, battery=False, on_done=None): this. Args: - battery (bool): If True, will run on battery devices as well, - defaults to skipping them. on_done: Finished callback. This is called when the command has completed. Signature is: on_done(success, msg, data) """ @@ -479,17 +479,57 @@ def get_engine_all(self, battery=False, on_done=None): # Reload all the device databases. for device in self.devices.values(): - if not battery and isinstance(device, (DevClass.BatterySensor, - DevClass.Leak, - DevClass.Remote)): - LOG.ui("Get engine all, skipping battery device %s", - device.label) - continue seq.add(device.get_engine) # Start the command sequence. seq.run() + #----------------------------------------------------------------------- + def join_all(self, on_done=None): + """Call Join on all Devices + + This calls join on all the devices. This can take a little time. It + is helpful when first setting up a network or replacing a PLM. + + Args: + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + # Set the error stop to false so a failed join doesn't stop the + # sequence from trying to join other devices. + seq = CommandSeq(self, "Join all complete", on_done, + error_stop=False, name="JoinAll") + + # Join all the device databases. + for device in self.devices.values(): + seq.add(device.join) + + # Start the command sequence. + seq.run() + + #----------------------------------------------------------------------- + def pair_all(self, on_done=None): + """Call Pair on all Devices + + This calls pair on all the devices. This can take a little time. It + is helpful when first setting up a network or replacing a PLM. + + Args: + on_done: Finished callback. This is called when the command has + completed. Signature is: on_done(success, msg, data) + """ + # Set the error stop to false so a failed pair doesn't stop the + # sequence from trying to pair other devices. + seq = CommandSeq(self, "Pair all complete", on_done, + error_stop=False, name="PairAll") + + # Pair all the device databases. + for device in self.devices.values(): + seq.add(device.pair) + + # Start the command sequence. + seq.run() + #----------------------------------------------------------------------- def get_devices(self, on_done=None): """"Print all the devices the modem knows about to the log UI. @@ -1155,18 +1195,8 @@ def run_command(self, **kwargs): { 'cmd' : 'COMMAND', ...args } where COMMAND is the command name and any additional arguments to the - command are other dictionary keywords. Valid commands are: - - getdb: No arguments. Download the PLM modem all link database - and save it to file. - - reload_all: No arguments. Reloads the modem database and tells - every device to reload it's database as well. - - factory_reset: No arguments. Full factory reset of the modem. - - set_btn: Optional time_out argument (in seconds). Simulates pressing - the modem set button to put the modem in linking mode. + command are other dictionary keywords. Valid commands are defined in + self.cmd_map. Args: kwargs: Command dictionary containing the arguments. diff --git a/insteon_mqtt/__init__.py b/insteon_mqtt/__init__.py index 5429a5a0..504f6bfd 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.8.3" +# __version__ is found in const.py #=========================================================================== @@ -25,6 +25,8 @@ from . import on_off from . import util +from .const import __version__ + from .Address import Address from .CommandSeq import CommandSeq from .Modem import Modem diff --git a/insteon_mqtt/cmd_line/main.py b/insteon_mqtt/cmd_line/main.py index 76eb6e7a..ab60dd98 100644 --- a/insteon_mqtt/cmd_line/main.py +++ b/insteon_mqtt/cmd_line/main.py @@ -5,11 +5,11 @@ #=========================================================================== import argparse import sys -import insteon_mqtt from .. import config from . import device from . import modem from . import start +from ..const import __version__ def parse_args(args): @@ -19,7 +19,7 @@ def parse_args(args): p = argparse.ArgumentParser(prog="insteon-mqtt", description="Insteon<->MQTT tool") p.add_argument('-v', '--version', action='version', version='%(prog)s ' + - insteon_mqtt.__version__) + __version__) p.add_argument("config", metavar="config.yaml", help="Configuration " "file to use.") sub = p.add_subparsers(help="Command help") @@ -42,9 +42,6 @@ def parse_args(args): "in the configuration.") sp.add_argument("-f", "--force", action="store_true", help="Force the modem/device database to be downloaded.") - sp.add_argument("--battery", action="store_true", - help="Refresh battery devices too, by default they are " - "skipped.") sp.add_argument("-q", "--quiet", action="store_true", help="Don't print any command results to the screen.") sp.set_defaults(func=modem.refresh_all) @@ -79,13 +76,26 @@ def parse_args(args): # modem.get_engine_all command sp = sub.add_parser("get-engine-all", help="Call get-engine on the " "devices in the configuration.") - sp.add_argument("--battery", action="store_true", - help="Run get-engine on battery devices too, by default " - "they are skipped.") sp.add_argument("-q", "--quiet", action="store_true", help="Don't print any command results to the screen.") sp.set_defaults(func=modem.get_engine_all) + #--------------------------------------- + # modem.join_all command + sp = sub.add_parser("join-all", help="Call join all on the devices " + "in the configuration.") + sp.add_argument("-q", "--quiet", action="store_true", + help="Don't print any command results to the screen.") + sp.set_defaults(func=modem.join_all) + + #--------------------------------------- + # modem.pair_all command + sp = sub.add_parser("pair-all", help="Call pair all on the devices " + "in the configuration.") + sp.add_argument("-q", "--quiet", action="store_true", + help="Don't print any command results to the screen.") + sp.set_defaults(func=modem.pair_all) + #--------------------------------------- # modem.factory_reset command sp = sub.add_parser("factory-reset", help="Perform a remote factory " diff --git a/insteon_mqtt/cmd_line/modem.py b/insteon_mqtt/cmd_line/modem.py index 657c26df..53c32097 100644 --- a/insteon_mqtt/cmd_line/modem.py +++ b/insteon_mqtt/cmd_line/modem.py @@ -11,7 +11,6 @@ def refresh_all(args, config): topic = "%s/modem" % (args.topic) payload = { "cmd" : "refresh_all", - "battery" : args.battery, "force" : args.force, } @@ -49,12 +48,30 @@ def get_engine_all(args, config): topic = "%s/modem" % (args.topic) payload = { "cmd" : "get_engine_all", - "battery" : args.battery, } reply = util.send(config, topic, payload, args.quiet) return reply["status"] +#=========================================================================== +def join_all(args, config): + topic = "%s/modem" % (args.topic) + payload = { + "cmd" : "join_all", + } + + reply = util.send(config, topic, payload, args.quiet) + return reply["status"] + +#=========================================================================== +def pair_all(args, config): + topic = "%s/modem" % (args.topic) + payload = { + "cmd" : "pair_all", + } + + reply = util.send(config, topic, payload, args.quiet) + return reply["status"] #=========================================================================== def factory_reset(args, config): diff --git a/insteon_mqtt/const.py b/insteon_mqtt/const.py new file mode 100644 index 00000000..f7e8cb5e --- /dev/null +++ b/insteon_mqtt/const.py @@ -0,0 +1,16 @@ +#=========================================================================== +# +# Insteon-MQTT Constants File +# +#=========================================================================== +""" Constants File + +This file can be used to store and import constant values throughout the +code. This was added after the code base had matured, so it is largely +unused. The primary impetus for this is to allow importing of the version +variable throughout the code without causing a cyclic import +""" + +__version__ = "0.8.3" + +#=========================================================================== diff --git a/insteon_mqtt/device/base/Base.py b/insteon_mqtt/device/base/Base.py index aea733f8..af649798 100644 --- a/insteon_mqtt/device/base/Base.py +++ b/insteon_mqtt/device/base/Base.py @@ -67,8 +67,6 @@ def from_config(cls, values, protocol, modem, **kwargs): if len(config) == 1: # This has no config_extra values addr, name = next(iter(config.items())) - if name: - name = name.lower() elif len(config) > 1: # This has config_extra values # Loop through each key. One and only one should be a @@ -85,7 +83,7 @@ def from_config(cls, values, protocol, modem, **kwargs): addrs_found.append(key) if len(addrs_found) == 1: addr = addrs_found[0] - name = config[addr].lower() + name = config[addr] config_extra = config.copy() del config_extra[addr] elif len(addrs_found) > 1: @@ -131,7 +129,10 @@ def __init__(self, protocol, modem, address, name=None, config_extra=None): self.protocol = protocol self.modem = modem self.addr = Address(address) + self.name_user_case = name self.name = name + if name is not None: + self.name = name.lower() self.config_extra = {} if config_extra is not None: self.config_extra = config_extra diff --git a/insteon_mqtt/handler/DeviceRefresh.py b/insteon_mqtt/handler/DeviceRefresh.py index 145d5af1..c51d08d0 100644 --- a/insteon_mqtt/handler/DeviceRefresh.py +++ b/insteon_mqtt/handler/DeviceRefresh.py @@ -111,6 +111,7 @@ def msg_received(self, protocol, msg): def on_done(success, message, data): if success: self.device.db.delta = msg.cmd1 + self.device.db.save() LOG.ui("%s database download complete\n%s", self.addr, self.device.db) self.on_done(success, message, data) diff --git a/insteon_mqtt/mqtt/BatterySensor.py b/insteon_mqtt/mqtt/BatterySensor.py index 152c4037..1fd87e42 100644 --- a/insteon_mqtt/mqtt/BatterySensor.py +++ b/insteon_mqtt/mqtt/BatterySensor.py @@ -11,7 +11,7 @@ LOG = log.get_logger() -class BatterySensor(topic.StateTopic): +class BatterySensor(topic.StateTopic, topic.DiscoveryTopic): """MQTT interface to an Insteon general battery powered sensor. This class connects to a device.BatterySensor object and converts it's @@ -44,6 +44,9 @@ def __init__(self, mqtt, device, **kwargs): device.signal_low_battery.connect(self._insteon_low_battery) device.signal_heartbeat.connect(self._insteon_heartbeat) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "battery_sensor" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -53,6 +56,9 @@ def load_config(self, config, qos=None): config is stored in config['battery_sensor']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("battery_sensor", None) if not data: return @@ -65,6 +71,13 @@ def load_config(self, config, qos=None): self.msg_heartbeat.load_config(data, 'heartbeat_topic', 'heartbeat_payload', qos) + # Add our unique topics to the discovery topic map + topics = {} + var_data = self.base_template_data() + topics['low_battery_topic'] = self.msg_battery.render_topic(var_data) + topics['heartbeat_topic'] = self.msg_heartbeat.render_topic(var_data) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def subscribe(self, link, qos): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/Dimmer.py b/insteon_mqtt/mqtt/Dimmer.py index 207a1a93..784d9983 100644 --- a/insteon_mqtt/mqtt/Dimmer.py +++ b/insteon_mqtt/mqtt/Dimmer.py @@ -13,7 +13,7 @@ class Dimmer(topic.StateTopic, topic.SceneTopic, topic.ManualTopic, - topic.SetTopic): + topic.SetTopic, topic.DiscoveryTopic): """MQTT interface to an Insteon dimmer switch. This class connects to a device.Dimmer object and converts it's output @@ -35,6 +35,9 @@ def __init__(self, mqtt, device): state_payload='{ "state" : "{{on_str.lower()}}", ' '"brightness" : {{level_255}} }') + # This defines the default discovery_class for these devices + self.default_discovery_cls = "dimmer" + # Input level command template. self.msg_level = MsgTemplate( topic='insteon/{{address}}/level', @@ -50,6 +53,9 @@ def load_config(self, config, qos=None): config is stored in config['dimmer']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("dimmer", None) if not data: return @@ -63,6 +69,11 @@ def load_config(self, config, qos=None): # Update the MQTT topics and payloads from the config file. self.msg_level.load_config(data, 'level_topic', 'level_payload', qos) + # Add our unique topics to the discovery topic map + self.rendered_topic_map['level_topic'] = self.msg_level.render_topic( + self.base_template_data() + ) + #----------------------------------------------------------------------- def subscribe(self, link, qos): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/EZIO4O.py b/insteon_mqtt/mqtt/EZIO4O.py index e110673a..9afec4ca 100644 --- a/insteon_mqtt/mqtt/EZIO4O.py +++ b/insteon_mqtt/mqtt/EZIO4O.py @@ -9,7 +9,7 @@ LOG = log.get_logger() -class EZIO4O(topic.StateTopic, topic.SetTopic): +class EZIO4O(topic.StateTopic, topic.SetTopic, topic.DiscoveryTopic): """MQTT interface to Smartenit EZIO4O 4 relay output device. This class connects to a device.EZIO4O object and converts it's @@ -32,6 +32,12 @@ def __init__(self, mqtt, device): state_topic='insteon/{{address}}/state/{{button}}', set_topic="insteon/{{address}}/set/{{button}}") + # This defines the default discovery_class for these devices + self.default_discovery_cls = "ezio4o" + + # Set the groups for discovery topic generation + self.extra_topic_nums = range(1, 5) + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -41,6 +47,9 @@ def load_config(self, config, qos=None): config is stored in config['ezio4o']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("ezio4o", None) if not data: return diff --git a/insteon_mqtt/mqtt/FanLinc.py b/insteon_mqtt/mqtt/FanLinc.py index 00e7cd33..fe2d5e3e 100644 --- a/insteon_mqtt/mqtt/FanLinc.py +++ b/insteon_mqtt/mqtt/FanLinc.py @@ -38,6 +38,9 @@ def __init__(self, mqtt, device): # Initialize the dimmer. super().__init__(mqtt, device) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "fan_linc" + # Output fan state change reporting template. self.msg_fan_state = MsgTemplate( topic='insteon/{{address}}/fan/state', @@ -67,7 +70,7 @@ def load_config(self, config, qos=None): qos (int): The default quality of service level to use. """ # Load the dimmer configuration from the dimmer area, not the fanlinc - # area. + # area. This will also load discovery items. super().load_config(config, qos) # Now load the fan control configuration. @@ -84,6 +87,19 @@ def load_config(self, config, qos=None): self.msg_fan_speed.load_config(data, 'fan_speed_set_topic', 'fan_speed_set_payload', qos) + # Add our unique topics to the discovery topic map + topics = {} + var_data = self.base_template_data() + topics['fan_state_topic'] = self.msg_fan_state.render_topic(var_data) + topics['fan_on_off_topic'] = self.msg_fan_on_off.render_topic(var_data) + topics['fan_speed_topic'] = self.msg_fan_speed_state.render_topic( + var_data + ) + topics['fan_speed_set_topic'] = self.msg_fan_speed.render_topic( + var_data + ) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def subscribe(self, link, qos): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/HiddenDoor.py b/insteon_mqtt/mqtt/HiddenDoor.py index 8a26d711..7b1dab00 100644 --- a/insteon_mqtt/mqtt/HiddenDoor.py +++ b/insteon_mqtt/mqtt/HiddenDoor.py @@ -39,6 +39,9 @@ def __init__(self, mqtt, device): device.signal_voltage.connect(self._insteon_voltage) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "hidden_door" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -58,6 +61,14 @@ def load_config(self, config, qos=None): self.msg_battery_voltage.load_config(data, 'battery_voltage_topic', 'battery_voltage_payload', qos) + # Add our unique topics to the discovery topic map + topics = {} + bat_volt = self.msg_battery_voltage + topics['battery_voltage_topic'] = bat_volt.render_topic( + self.base_template_data() + ) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def template_data_hidden_door(self, is_dawn=None, batt_volt=None, low_batt_volt=None, hb_interval=None): diff --git a/insteon_mqtt/mqtt/IOLinc.py b/insteon_mqtt/mqtt/IOLinc.py index 92145c1f..74bb84b9 100644 --- a/insteon_mqtt/mqtt/IOLinc.py +++ b/insteon_mqtt/mqtt/IOLinc.py @@ -10,7 +10,7 @@ LOG = log.get_logger() -class IOLinc(topic.StateTopic, topic.SetTopic): +class IOLinc(topic.StateTopic, topic.SetTopic, topic.DiscoveryTopic): """MQTT interface to an Insteon IOLinc device. This class connects to a device.IOLinc object and converts it's @@ -40,6 +40,9 @@ def __init__(self, mqtt, device): device.signal_state.connect(self._insteon_on_off) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "io_linc" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -49,6 +52,9 @@ def load_config(self, config, qos=None): config is stored in config['io_linc']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("io_linc", None) if not data: return @@ -60,6 +66,17 @@ def load_config(self, config, qos=None): 'sensor_state_payload', qos) self.load_set_data(data, qos) + # Add our unique topics to the discovery topic map + topics = {} + var_data = self.base_template_data() + topics['relay_state_topic'] = self.msg_relay_state.render_topic( + var_data + ) + topics['sensor_state_topic'] = self.msg_sensor_state.render_topic( + var_data + ) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def subscribe(self, link, qos): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/KeypadLinc.py b/insteon_mqtt/mqtt/KeypadLinc.py index 0798c81b..0f1150e3 100644 --- a/insteon_mqtt/mqtt/KeypadLinc.py +++ b/insteon_mqtt/mqtt/KeypadLinc.py @@ -5,12 +5,13 @@ #=========================================================================== from .. import log from . import topic +from ..device.base import DimmerBase LOG = log.get_logger() class KeypadLinc(topic.SetTopic, topic.SceneTopic, topic.StateTopic, - topic.ManualTopic): + topic.ManualTopic, topic.DiscoveryTopic): """MQTT interface to an Insteon KeypadLinc switch. This class connects to a device.KeypadLinc object and converts it's output @@ -31,6 +32,12 @@ def __init__(self, mqtt, device, **kwargs): set_topic='insteon/{{address}}/set/{{button}}', **kwargs) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "keypad_linc" + + # Set the groups for discovery topic generation + self.extra_topic_nums = range(1, 10) + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -40,6 +47,9 @@ def load_config(self, config, qos=None): config is stored in config['keypad_linc']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("keypad_linc", None) if not data: return @@ -86,3 +96,24 @@ def unsubscribe(self, link): for group in range(1, 9): self.set_unsubscribe(link, group=group) self.scene_unsubscribe(link, group=group) + + #----------------------------------------------------------------------- + def discovery_template_data(self, **kwargs): + """Create the Jinja templating data variables for discovery messages. + + This extends the default dict with additional variables supported + by this device + + Returns: + dict: Returns a dict with the variables available for templating. + including: + """ + # Get the default variables + # pylint:disable=E1101 + data = super().discovery_template_data(**kwargs) + data['is_dimmable'] = False + if isinstance(self.device, DimmerBase): + data['is_dimmable'] = True + return data + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/KeypadLincDimmer.py b/insteon_mqtt/mqtt/KeypadLincDimmer.py index 954ab1aa..2e68644c 100644 --- a/insteon_mqtt/mqtt/KeypadLincDimmer.py +++ b/insteon_mqtt/mqtt/KeypadLincDimmer.py @@ -54,6 +54,13 @@ def load_config(self, config, qos=None): self.msg_dimmer_level.load_config(data, 'dimmer_level_topic', 'dimmer_level_payload', qos) + # Add our unique topics to the discovery topic map + topics = {} + topics['dimmer_level_topic'] = self.msg_dimmer_level.render_topic( + self.base_template_data() + ) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def subscribe(self, link, qos, start_group=1): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/Leak.py b/insteon_mqtt/mqtt/Leak.py index 0fd524e0..fdccd8e1 100644 --- a/insteon_mqtt/mqtt/Leak.py +++ b/insteon_mqtt/mqtt/Leak.py @@ -27,6 +27,9 @@ def __init__(self, mqtt, device): state_topic='insteon/{{address}}/wet', state_payload='{{is_wet_str.lower()}}') + # This defines the default discovery_class for these devices + self.default_discovery_cls = "leak" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -53,6 +56,12 @@ def load_config(self, config, qos=None): self.msg_heartbeat.load_config(data, 'heartbeat_topic', 'heartbeat_payload', qos) + # Add our unique topics to the discovery topic map + # The state_topic uses a different name on the leak sensor + if 'state_topic' in self.rendered_topic_map: + rendered_topic = self.rendered_topic_map.pop('state_topic') + self.rendered_topic_map['wet_dry_topic'] = rendered_topic + #----------------------------------------------------------------------- def state_template_data(self, **kwargs): """Create the Jinja templating data variables for on/off messages. @@ -95,3 +104,5 @@ def state_template_data(self, **kwargs): data["state"] = "wet" if is_wet else "dry" return data + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/Modem.py b/insteon_mqtt/mqtt/Modem.py index caf9ac57..8e313995 100644 --- a/insteon_mqtt/mqtt/Modem.py +++ b/insteon_mqtt/mqtt/Modem.py @@ -3,12 +3,14 @@ # MQTT PLM modem device # #=========================================================================== +import re from .. import log from . import topic +from .MsgTemplate import MsgTemplate LOG = log.get_logger() -class Modem(topic.SceneTopic): +class Modem(topic.SceneTopic, topic.DiscoveryTopic): """MQTT interface to an Insteon power line modem (PLM). This class connects to an insteon_mqtt.Modem object and allows input MQTT @@ -26,6 +28,12 @@ def __init__(self, mqtt, modem): scene_payload='{ "cmd" : "{{json.cmd.lower()}}",' '"group" : {{json.group}} }') + # This defines the default discovery_class for these devices + self.default_discovery_cls = "modem" + + # Set the groups for discovery topic generation + # self.extra_topic_nums = range(2, 255) + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -41,6 +49,66 @@ def load_config(self, config, qos=None): self.load_scene_data(data, qos) + # Load Discovery Data, Modem uses a slightly different process than + # all other devices. It only uses a single template, but needs to + # pass a variable in the topic + if not self.mqtt.discovery_enabled: + return + + class_config = config.get(self.default_discovery_cls, None) + if class_config is None: + LOG.error("%s - Unable to find discovery class %s", + self.device.label, self.default_discovery_cls) + return + + # Loop all of the discovery entities and append them to + # self.rendered_topic_map + entities = class_config.get('discovery_entities', None) + if entities is None or not isinstance(entities, list): + LOG.error("%s - No discovery_entities defined, or not a list %s", + self.device.label, entities) + return + + if len(entities) > 1: + LOG.warning("%s - Modem only uses the first discovery_entity, " + "ignoring the rest %s", self.device.label, entities) + + entity = entities[0] + component = entity.get('component', None) + if component is None: + LOG.error("%s - No component specified in discovery entity %s", + self.device.label, entity) + return + + payload = entity.get('config', None) + if payload is None: + LOG.error("%s - No config specified in discovery entity %s", + self.device.label, entity) + return + + # Get Unique ID from payload to use in topic + unique_id = self._get_unique_id(payload) + if unique_id is None: + LOG.error("%s - Error getting unique_id, skipping entry", + self.device.label) + return + + # HA's implementation of discovery only allows a very limited + # range of characters in the node_id and object_id fields. + # See line #30 of /homeassistant/components/mqtt/discovery.py + # Replace any not-allowed character with underscore + topic_base = self.mqtt.discovery_topic_base + address_safe = re.sub(r'[^a-zA-Z0-9_-]', '_', self.device.addr.hex) + unique_id_safe = re.sub(r'[^a-zA-Z0-9_-]', '_', unique_id) + default_topic = "%s/%s/%s/%s/config" % (topic_base, + component, + address_safe, + unique_id_safe + "_{{scene}}") + self.disc_templates.append(MsgTemplate(topic=default_topic, + payload=payload, + qos=qos, + retain=False)) + #----------------------------------------------------------------------- def subscribe(self, link, qos): """Subscribe to any MQTT topics the object needs. @@ -64,3 +132,39 @@ def unsubscribe(self, link): self.scene_unsubscribe(link) #----------------------------------------------------------------------- + def publish_discovery(self, **kwargs): + """This Hijacks the method from DiscoveryTopic + + This is necessary because the Modem is a singular device that requires + a little different handling to publish the available scenes. + + This is triggered from the MQTT handler. + + No kwargs are currently sent from the MQTT handler, it is a little + hard to imagine how any such arguments could be provided but left here + for potential use. + + Args: + kwargs (dict): The arguments to pass to discovery_template_data + """ + LOG.info("MQTT discovery %s on: %s", self.device.label, kwargs) + + data = self.discovery_template_data(**kwargs) + + for scene in self.device.db.groups: + if scene < 2: + # Don't publish scenes 0/1, they are not real scenes + continue + # Try and load the scene name if it exists + scene_map = self.device.scene_map + try: + scene_index = list(scene_map.values()).index(scene) + data['scene_name'] = list(scene_map.keys())[scene_index] + except ValueError: + # scene does not have a name + data['scene_name'] = "" + data['scene'] = scene + self.disc_templates[0].publish(self.mqtt, data.copy(), + retain=False) + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/Motion.py b/insteon_mqtt/mqtt/Motion.py index 9f7c23ca..d77fb257 100644 --- a/insteon_mqtt/mqtt/Motion.py +++ b/insteon_mqtt/mqtt/Motion.py @@ -36,6 +36,9 @@ def __init__(self, mqtt, device): device.signal_dawn.connect(self._insteon_dawn) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "motion" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -64,6 +67,13 @@ def load_config(self, config, qos=None): self.msg_battery.load_config(data, 'low_battery_topic', 'low_battery_payload', qos) + # Add our unique topics to the discovery topic map + topics = {} + topics['dawn_dusk_topic'] = self.msg_dawn.render_topic( + self.base_template_data() + ) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def template_data_motion(self, is_dawn=None): """Create the Jinja templating data variables. diff --git a/insteon_mqtt/mqtt/Mqtt.py b/insteon_mqtt/mqtt/Mqtt.py index d047bc76..7927e7ad 100644 --- a/insteon_mqtt/mqtt/Mqtt.py +++ b/insteon_mqtt/mqtt/Mqtt.py @@ -40,6 +40,9 @@ class Mqtt: implements for various things. The payload for these messages is always a json data object that will get passed to the Insteon device for handling + + This class also handles the HomeAssistant status topic and triggers the + devices to publish their discovery entities when necessary. """ def __init__(self, mqtt_link, modem): """Constructor @@ -65,6 +68,18 @@ def __init__(self, mqtt_link, modem): # The command topic template (MstTemplate) to use. self._cmd_topic = None + # Enable discovery service + self.discovery_enabled = False + + # The HomeAssistant status topic to use. + self._ha_status_topic = None + + # The device_info_template + self.device_info_template = "" + + # The discovery base topic, None if not enabled + self.discovery_topic_base = None + # MQTT message parameters. These get loaded via the config. self.qos = 1 self.retain = True @@ -83,7 +98,7 @@ def load_config(self, data): - broker: (str) The broker host to connect to. - port: (int) Thr broker port to connect to. - username: (str) Optional user name to log in with. - - passord: (str) Optional password to log in with. + - password: (str) Optional password to log in with. - qos: (int) QOS level to use for sent messages (Default 1). - retain: (bool) Retain sent messages (Default True) @@ -100,6 +115,26 @@ def load_config(self, data): # Create a template for prcessing messages on the command topic. self._cmd_topic = MsgTemplate.clean_topic(data['cmd_topic']) + # Create a template for prcessing HomeAssistant status messages. + if 'discovery_ha_status' in data: + self._ha_status_topic = MsgTemplate.clean_topic( + data['discovery_ha_status'] + ) + + # Load the device_info_template if defined this is a variable shared + # by all devices + if 'device_info_template' in data: + self.device_info_template = data['device_info_template'] + + # Check to see that discovery_topic_base is set in config + self.discovery_topic_base = data.get('discovery_topic_base', + "homeassistant") + + # Check if discovery enabled + self.discovery_enabled = data.get('enable_discovery', False) + if not self.discovery_enabled: + LOG.debug("Discovery disabled via config setting.") + # MQTT message parameters. self.qos = data.get('qos', self.qos) self.retain = data.get('retain', self.retain) @@ -109,7 +144,7 @@ def load_config(self, data): # Subscribe to the new topics. if self.link.connected: - self._subscribe() + self._startup() #----------------------------------------------------------------------- def publish(self, topic, payload, qos=None, retain=None): @@ -152,7 +187,7 @@ def handle_connected(self, link, connected): connected (bool): True if connected, False if disconnected. """ if self.link.connected: - self._subscribe() + self._startup() #----------------------------------------------------------------------- def handle_new_device(self, modem, device): @@ -185,8 +220,10 @@ def handle_new_device(self, modem, device): self.devices[device.addr.id] = obj # If we are already connected we need to subscribe this device + # and publish its discovery entities if self.link.connected: obj.subscribe(self.link, self.qos) + self._publish_device_discovery(obj) #----------------------------------------------------------------------- def handle_cmd(self, client, userdata, message): @@ -325,21 +362,71 @@ def handle_reply(self, record, topic): self.link.publish(topic, payload) #----------------------------------------------------------------------- - def _subscribe(self): - """Subscribe to the command and set topics. + def handle_ha_status(self, client, userdata, message): + """HomeAssistant Status Topic Monitoring + + See https://www.home-assistant.io/docs/mqtt/birth_will/ + This monitors an MQTT topic where HomeAssistant publishes 'online' + and 'offline' status messages. When a 'online' message is received + it signals that HomeAssistant was restarted and requires the + Discovery Entities to be published againe. When 'online' is + received this method will trigger all devices to re-publish their + Discovery Entities. + + Args: + client (paho.Client): The paho mqtt client (self.link). + data: Optional user data (unused). + message: MQTT message - has attrs: topic, payload, qos, retain. + """ + LOG.info("MQTT message %s %s", message.topic, message.payload) + + payload = message.payload.decode("utf-8").strip().lower() + if payload == 'online': + for device in self.devices.values(): + self._publish_device_discovery(device) + elif payload != 'offline': + LOG.warning("Unexpected HomeAssistant status message %s %s", + message.topic, message.payload) + + #----------------------------------------------------------------------- + def _publish_device_discovery(self, device): + """Trigger a device to publish its discovery entities + + Checks that discovery is enabled and that the device supports + discovery. + + Args: + device: the device to send the publish discovery command + """ + if self.discovery_enabled: + if (hasattr(device, 'publish_discovery') and + callable(device.publish_discovery)): + device.publish_discovery() + + #----------------------------------------------------------------------- + def _startup(self): + """Startup Process When MQTT Broker Comes Online This will subscribe to the command topic and tell all the MQTT devices to subscribe to their command topics. + + It will also subscribe to the HomeAssistant status topic and trigger + all devices to publish their discovery entities """ if self._cmd_topic: self.link.subscribe(self._cmd_topic + "/+", self.qos, self.handle_cmd) + if self._ha_status_topic: + self.link.subscribe(self._ha_status_topic, self.qos, + self.handle_ha_status) + for device in self.devices.values(): device.subscribe(self.link, self.qos) + self._publish_device_discovery(device) #----------------------------------------------------------------------- - def _unsubscribe(self): + def _shutdown(self): """Unsubscribe to the command and set topics. This will unsubscribe from all the topics. diff --git a/insteon_mqtt/mqtt/Outlet.py b/insteon_mqtt/mqtt/Outlet.py index 5308a4fc..23ea12fa 100644 --- a/insteon_mqtt/mqtt/Outlet.py +++ b/insteon_mqtt/mqtt/Outlet.py @@ -9,7 +9,7 @@ LOG = log.get_logger() -class Outlet(topic.SetTopic, topic.StateTopic): +class Outlet(topic.SetTopic, topic.StateTopic, topic.DiscoveryTopic): """MQTT interface to an Insteon on/off outlet. This class connects to a device.Outlet object and converts it's @@ -31,6 +31,12 @@ def __init__(self, mqtt, device): state_topic='insteon/{{address}}/state/{{button}}', set_topic='insteon/{{address}}/set/{{button}}') + # This defines the default discovery_class for these devices + self.default_discovery_cls = "outlet" + + # Set the groups for discovery topic generation + self.extra_topic_nums = (1, 2) + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -40,6 +46,9 @@ def load_config(self, config, qos=None): config is stored in config['outlet']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("outlet", None) if not data: return diff --git a/insteon_mqtt/mqtt/Remote.py b/insteon_mqtt/mqtt/Remote.py index 650823f2..e7f15f89 100644 --- a/insteon_mqtt/mqtt/Remote.py +++ b/insteon_mqtt/mqtt/Remote.py @@ -40,6 +40,12 @@ def __init__(self, mqtt, device): # retained message? - KRKeegan 2021-01-10 self.state_retain = False + # This defines the default discovery_class for these devices + self.default_discovery_cls = "remote" + + # Set the groups for discovery topic generation + self.extra_topic_nums = range(1, 9) + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. diff --git a/insteon_mqtt/mqtt/SmokeBridge.py b/insteon_mqtt/mqtt/SmokeBridge.py index 84c7dc26..e496da13 100644 --- a/insteon_mqtt/mqtt/SmokeBridge.py +++ b/insteon_mqtt/mqtt/SmokeBridge.py @@ -5,12 +5,13 @@ #=========================================================================== from .. import log from .. import device as IDev +from . import topic from .MsgTemplate import MsgTemplate LOG = log.get_logger() -class SmokeBridge: +class SmokeBridge(topic.DiscoveryTopic): """MQTT interface to an Insteon smoke bridge. This class connects to a device.SmokeBridge object and converts it's @@ -25,8 +26,8 @@ def __init__(self, mqtt, device): mqtt (mqtt.Mqtt): The MQTT main interface. device (device.SmokeBridge): The Insteon object to link to. """ - self.mqtt = mqtt - self.device = device + # Setup the BaseTopic + super().__init__(mqtt, device) # Set up the default templates for the MQTT messages and payloads. self.msg_smoke = MsgTemplate( @@ -45,6 +46,9 @@ def __init__(self, mqtt, device): # Receive notifications from the Insteon device when it changes. device.signal_on_off.connect(self._insteon_change) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "smoke_bridge" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -54,6 +58,9 @@ def load_config(self, config, qos=None): config is stored in config['smoke_bridge']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("smoke_bridge", None) if not data: return @@ -65,6 +72,15 @@ def load_config(self, config, qos=None): qos) self.msg_error.load_config(data, 'error_topic', 'error_payload', qos) + # Add our unique topics to the discovery topic map + topics = {} + var_data = self.base_template_data() + topics['smoke_topic'] = self.msg_smoke.render_topic(var_data) + topics['co_topic'] = self.msg_co.render_topic(var_data) + topics['battery_topic'] = self.msg_battery.render_topic(var_data) + topics['error_topic'] = self.msg_error.render_topic(var_data) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def subscribe(self, link, qos): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/Switch.py b/insteon_mqtt/mqtt/Switch.py index d50487df..e026a246 100644 --- a/insteon_mqtt/mqtt/Switch.py +++ b/insteon_mqtt/mqtt/Switch.py @@ -10,7 +10,7 @@ class Switch(topic.SetTopic, topic.StateTopic, topic.SceneTopic, - topic.ManualTopic): + topic.ManualTopic, topic.DiscoveryTopic): """MQTT interface to an Insteon on/off switch. This class connects to a device.Switch object and converts it's @@ -30,6 +30,9 @@ def __init__(self, mqtt, device): # Setup the Topics super().__init__(mqtt, device) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "switch" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -39,6 +42,9 @@ def load_config(self, config, qos=None): config is stored in config['switch']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("switch", None) if not data: return diff --git a/insteon_mqtt/mqtt/Thermostat.py b/insteon_mqtt/mqtt/Thermostat.py index da18f0d3..d73cd8e3 100644 --- a/insteon_mqtt/mqtt/Thermostat.py +++ b/insteon_mqtt/mqtt/Thermostat.py @@ -4,12 +4,13 @@ # #=========================================================================== from .. import log +from . import topic from .MsgTemplate import MsgTemplate LOG = log.get_logger() -class Thermostat: +class Thermostat(topic.DiscoveryTopic): """MQTT interface to an Insteon thermostat switch. This class connects to a device.Thermostat and converts it's output @@ -23,8 +24,8 @@ def __init__(self, mqtt, device): mqtt (mqtt.Mqtt): The MQTT main interface. device (device.Thermostat): The Insteon object to link to. """ - self.mqtt = mqtt - self.device = device + # Setup the BaseTopic + super().__init__(mqtt, device) # Set up the default templates for the MQTT messages and payloads. # Templates for states @@ -83,6 +84,9 @@ def __init__(self, mqtt, device): device.signal_hold_change.connect(self._insteon_hold_change) device.signal_energy_change.connect(self._insteon_energy_change) + # This defines the default discovery_class for these devices + self.default_discovery_cls = "thermostat" + #----------------------------------------------------------------------- def load_config(self, config, qos=None): """Load values from a configuration data object. @@ -92,6 +96,9 @@ def load_config(self, config, qos=None): config is stored in config['thermostat']. qos (int): The default quality of service level to use. """ + # The discovery topic needs the full config + self.load_discovery_data(config, qos) + data = config.get("thermostat", None) if not data: return @@ -124,6 +131,32 @@ def load_config(self, config, qos=None): self.cool_sp_command.load_config(data, 'cool_sp_command_topic', 'cool_sp_command_payload', qos) + # Add our unique topics to the discovery topic map + topics = {} + var_data = self.base_template_data() + topics['ambient_temp_topic'] = self.ambient_temp.render_topic(var_data) + topics['fan_state_topic'] = self.fan_state.render_topic(var_data) + topics['mode_state_topic'] = self.mode_state.render_topic(var_data) + topics['cool_sp_state_topic'] = self.cool_sp_state.render_topic( + var_data + ) + topics['heat_sp_state_topic'] = self.heat_sp_state.render_topic( + var_data + ) + topics['humid_state_topic'] = self.humid_state.render_topic(var_data) + topics['status_state_topic'] = self.status_state.render_topic(var_data) + topics['hold_state_topic'] = self.hold_state.render_topic(var_data) + topics['energy_state_topic'] = self.energy_state.render_topic(var_data) + topics['mode_command_topic'] = self.mode_command.render_topic(var_data) + topics['fan_command_topic'] = self.fan_command.render_topic(var_data) + topics['heat_sp_command_topic'] = self.heat_sp_command.render_topic( + var_data + ) + topics['cool_sp_command_topic'] = self.cool_sp_command.render_topic( + var_data + ) + self.rendered_topic_map.update(topics) + #----------------------------------------------------------------------- def subscribe(self, link, qos): """Subscribe to any MQTT topics the object needs. @@ -135,17 +168,18 @@ def subscribe(self, link, qos): link (network.Mqtt): The MQTT network client to use. qos (int): The quality of service to use. """ - topic = self.mode_command.render_topic(self.template_data()) - link.subscribe(topic, qos, self._input_mode) + data = self.template_data() + rendered_topic = self.mode_command.render_topic(data) + link.subscribe(rendered_topic, qos, self._input_mode) - topic = self.fan_command.render_topic(self.template_data()) - link.subscribe(topic, qos, self._input_fan) + rendered_topic = self.fan_command.render_topic(data) + link.subscribe(rendered_topic, qos, self._input_fan) - topic = self.heat_sp_command.render_topic(self.template_data()) - link.subscribe(topic, qos, self._input_heat_setpoint) + rendered_topic = self.heat_sp_command.render_topic(data) + link.subscribe(rendered_topic, qos, self._input_heat_setpoint) - topic = self.cool_sp_command.render_topic(self.template_data()) - link.subscribe(topic, qos, self._input_cool_setpoint) + rendered_topic = self.cool_sp_command.render_topic(data) + link.subscribe(rendered_topic, qos, self._input_cool_setpoint) #----------------------------------------------------------------------- def unsubscribe(self, link): @@ -154,17 +188,18 @@ def unsubscribe(self, link): Args: link (network.Mqtt): The MQTT network client to use. """ - topic = self.mode_command.render_topic(self.template_data()) - link.unsubscribe(topic) + data = self.template_data() + rendered_topic = self.mode_command.render_topic(data) + link.unsubscribe(rendered_topic) - topic = self.fan_command.render_topic(self.template_data()) - link.unsubscribe(topic) + rendered_topic = self.fan_command.render_topic(data) + link.unsubscribe(rendered_topic) - topic = self.heat_sp_command.render_topic(self.template_data()) - link.unsubscribe(topic) + rendered_topic = self.heat_sp_command.render_topic(data) + link.unsubscribe(rendered_topic) - topic = self.cool_sp_command.render_topic(self.template_data()) - link.unsubscribe(topic) + rendered_topic = self.cool_sp_command.render_topic(data) + link.unsubscribe(rendered_topic) #----------------------------------------------------------------------- def template_data(self): diff --git a/insteon_mqtt/mqtt/topic/BaseTopic.py b/insteon_mqtt/mqtt/topic/BaseTopic.py index 4507efd7..8ac9c886 100644 --- a/insteon_mqtt/mqtt/topic/BaseTopic.py +++ b/insteon_mqtt/mqtt/topic/BaseTopic.py @@ -3,6 +3,7 @@ # MQTT Base Topic # #=========================================================================== +import time from ... import log LOG = log.get_logger() @@ -21,12 +22,26 @@ def __init__(self, mqtt, device): self.mqtt = mqtt self.device = device + # This defines the default class name that is used when searching for + # discovery templates. + self.default_discovery_cls = None + + # Any topics added here are available in discovery templates using the + # key as the variable name. The key should be the yaml key for the + # topic + self.rendered_topic_map = {} + + # This should be a list of group numbers for which state, set, and + # scene topics will be generated such as state_topic_1, if empty + # only the default state topics and command topics will be generated + self.extra_topic_nums = [] + #----------------------------------------------------------------------- def base_template_data(self, **kwargs): """Create the Jinja templating data variables for use in topics. As the base template, this provides the immutable values such as - address and name. + address, name, and timestamp. Args: button (int): The button (group) ID (1-8) of the Insteon button @@ -36,7 +51,8 @@ def base_template_data(self, **kwargs): dict: Returns a dict with the variables available for templating. """ data = {"address" : self.device.addr.hex, - "name" : self.device.addr.hex} + "name" : self.device.addr.hex, + "timestamp": int(time.time())} if self.device.name: data['name'] = self.device.name if 'button' in kwargs and kwargs['button'] is not None: diff --git a/insteon_mqtt/mqtt/topic/DiscoveryTopic.py b/insteon_mqtt/mqtt/topic/DiscoveryTopic.py new file mode 100644 index 00000000..60a42290 --- /dev/null +++ b/insteon_mqtt/mqtt/topic/DiscoveryTopic.py @@ -0,0 +1,255 @@ +#=========================================================================== +# +# MQTT Discovery Topic +# +#=========================================================================== +import re +import json +import jinja2 +from ... import log +from ...catalog import Category +from ..MsgTemplate import MsgTemplate +from .BaseTopic import BaseTopic + +LOG = log.get_logger() + + +class DiscoveryTopic(BaseTopic): + """MQTT interface to the Discovery Topic + + This is an abstract class that provides support for the Discovery topic. + All devices that support MQTT discovery should inherit this. + + Note that a call to load_discovery_data will need to be made from the + extended classes load_config method. Plus any devices adding additional + variables should extend discovery_template_data. + """ + def __init__(self, mqtt, device, **kwargs): + """Discovery Topic Constructor + + Args: + device (device): The Insteon object to link to. + mqtt (mqtt.Mqtt): The MQTT main interface. + """ + super().__init__(mqtt, device, **kwargs) + + # This is a list of all of the discovery entries published by this + # device + self.disc_templates = [] + + #----------------------------------------------------------------------- + def load_discovery_data(self, config, qos=None): + """Load values from a configuration data object. + + This should be called inside the device load_config() method. Note + that it takes the full mqtt config, not just the device subsection. + + Args: + config (dict): The mqtt section of the config dict. + qos (int): The default quality of service level to use. + """ + # Skip is discovery not enabled + if not self.mqtt.discovery_enabled: + return + + # Get the device specific discovery class + disc_class = self.device.config_extra.get('discovery_class', + self.default_discovery_cls) + class_config = config.get(disc_class, None) + if class_config is None: + LOG.error("%s - Unable to find discovery class %s", + self.device.label, disc_class) + return + + # Loop all of the discovery entities and append them to + # self.rendered_topic_map + entities = class_config.get('discovery_entities', None) + if entities is None or not isinstance(entities, list): + LOG.error("%s - No discovery_entities defined, or not a list %s", + self.device.label, entities) + return + for entity in entities: + component = entity.get('component', None) + if component is None: + LOG.error("%s - No component specified in discovery entity %s", + self.device.label, entity) + continue + + payload = entity.get('config', None) + if payload is None: + LOG.error("%s - No config specified in discovery entity %s", + self.device.label, entity) + continue + + # Get Unique ID from payload to use in topic + unique_id = self._get_unique_id(payload) + if unique_id is None: + LOG.error("%s - Error getting unique_id, skipping entry", + self.device.label) + continue + + # HA's implementation of discovery only allows a very limited + # range of characters in the node_id and object_id fields. + # See line #30 of /homeassistant/components/mqtt/discovery.py + # Replace any not-allowed character with underscore + topic_base = self.mqtt.discovery_topic_base + address_safe = re.sub(r'[^a-zA-Z0-9_-]', '_', self.device.addr.hex) + unique_id_safe = re.sub(r'[^a-zA-Z0-9_-]', '_', unique_id) + default_topic = "%s/%s/%s/%s/config" % (topic_base, + component, + address_safe, + unique_id_safe) + self.disc_templates.append(MsgTemplate(topic=default_topic, + payload=payload, + qos=qos, + retain=False)) + + #----------------------------------------------------------------------- + def discovery_template_data(self, **kwargs): + """Create the Jinja templating data variables for discovery messages. + + This should be extended by specific devices when adding additional + variables is needed, particularly when adding unique topics from + the yaml file. + + kwargs are pass from the publish_discovery method and are not used + in this class. + + This is run in load_discovery_data() to get the unique_id which is + before the topics are created, so the topic variables cannot be used as + part of the unique_id. This is fine, but be prepared to gracefully + handle the absence of topics in any extension of this method. + + Returns: + dict: Returns a dict with the variables available for templating. + including: + name = (str) device name in lower case + address = (str) hexadecimal address of device as a string + name_user_case = (str) device name in the case entered by + the user + engine = (str) device engine version (e.g. i1, i2, i2cs) + model_number = (str) device model number (e.g. 2476D) + model_description = (str) description (e.g. SwitchLinc Dimmer) + firmware = (int) device firmware version + dev_cat = (int) device category + dev_cat_name = (str) device category name + sub_cat = (int) device sub-category + modem_addr = (str) hexadecimal address of modem as a string + device_info_template = (jinja template) a template defined in + config.yaml + <> = (str) topic keys as defined in the config.yaml + file are available as variables + """ + # Set up the variables that can be used in the templates. + data = self.base_template_data(**kwargs) + + # Insert Topics from topic classes + data.update(self.rendered_topic_map) + + data['name_user_case'] = self.device.addr.hex + if self.device.name_user_case: + data['name_user_case'] = self.device.name_user_case + + engine_map = {0: 'i1', 1: 'i2', 2: 'i2cs'} + data['engine'] = 'Unknown' + if hasattr(self.device.db, 'engine'): + data['engine'] = engine_map.get(self.device.db.engine, 'Unknown') + data['model_number'] = 'Unknown' + data['model_description'] = 'Unknown' + data['dev_cat'] = 0 + data['dev_cat_name'] = 'Unknown' + data['sub_cat'] = 0 + if self.device.db.desc is not None: + data['model_number'] = self.device.db.desc.model + data['model_description'] = self.device.db.desc.description + data['dev_cat'] = int(self.device.db.desc.dev_cat) + if isinstance(self.device.db.desc.dev_cat, Category): + data['dev_cat_name'] = self.device.db.desc.dev_cat.name + data['sub_cat'] = self.device.db.desc.sub_cat + data['firmware'] = 0 + if self.device.db.firmware is not None: + data['firmware'] = self.device.db.firmware + data['modem_addr'] = data['address'] + if hasattr(self.device, 'modem'): + data['modem_addr'] = self.device.modem.addr.hex + + # Finally, render the device_info_template + try: + device_info_template = jinja2.Template( + self.mqtt.device_info_template + ) + data['device_info_template'] = device_info_template.render(data) + except jinja2.exceptions.TemplateError as exc: + LOG.error("Error rendering device_info_template: %s", exc) + LOG.error("Template was: \n%s", + self.mqtt.device_info_template.strip()) + LOG.error("Data passed was: %s", data) + + return data + + #----------------------------------------------------------------------- + def publish_discovery(self, **kwargs): + """Publish the Discovery Message + + This is triggered from the MQTT handler. + + No kwargs are currently sent from the MQTT handler, it is a little + hard to imagine how any such arguments could be provided but left here + for potential use. + + Args: + kwargs (dict): The arguments to pass to discovery_template_data + """ + LOG.info("Publishing discovery for %s kwargs: %s", + self.device.label, kwargs) + + data = self.discovery_template_data(**kwargs) + + for entry in self.disc_templates: + entry.publish(self.mqtt, data, retain=False) + + #----------------------------------------------------------------------- + def _get_unique_id(self, config): + """Extracts the unique id from the rendered payload. + + This renders the discovery payload, then decodes the json payload + back into a dict and extracts the unique_id. This may seem a little + circuitous, but any solution requires rendering of the config and + json parsing if we want to know the unique_id without requiring the + user to enter it twice. + + Args: + config (dict): A single entity from the discovery_entities key. + + Returns: + unique_id (str) or None if there was an error. + """ + data = self.discovery_template_data() + ret = None + # First render template + try: + config_template = jinja2.Template(config) + config_rendered = config_template.render(data) + except jinja2.exceptions.TemplateError as exc: + LOG.error("Error rendering config template: %s", exc) + LOG.error("Template was: \n%s", + config.strip()) + LOG.error("Data passed was: %s", data) + else: + # Second, parse rendered result as json + try: + config_json = json.loads(config_rendered) + except json.JSONDecodeError as exc: + LOG.error("Error parsing config as json: %s", exc) + LOG.error("Config output was: \n%s", + config_rendered.strip()) + else: + # Third check for existence of unique_id or uniq_id + ret = config_json.get('unique_id', + config_json.get('uniq_id', None)) + if ret is None: + LOG.error("Unique_id was not specified in config: %s", + config_rendered) + return ret + + #----------------------------------------------------------------------- diff --git a/insteon_mqtt/mqtt/topic/ManualTopic.py b/insteon_mqtt/mqtt/topic/ManualTopic.py index a7794445..a470bc8b 100644 --- a/insteon_mqtt/mqtt/topic/ManualTopic.py +++ b/insteon_mqtt/mqtt/topic/ManualTopic.py @@ -75,6 +75,11 @@ def load_manual_data(self, data, qos=None, topic=None, payload=None): # Update the MQTT topics and payloads from the config file. self.msg_manual_state.load_config(data, topic, payload, qos) + # Add ourselves to the list of topics + self.rendered_topic_map[topic] = self.msg_manual_state.render_topic( + self.base_template_data() + ) + #----------------------------------------------------------------------- def publish_manual(self, device, **kwargs): """Device Manual Change Callback. diff --git a/insteon_mqtt/mqtt/topic/SceneTopic.py b/insteon_mqtt/mqtt/topic/SceneTopic.py index 22e6ab3b..2692ee60 100644 --- a/insteon_mqtt/mqtt/topic/SceneTopic.py +++ b/insteon_mqtt/mqtt/topic/SceneTopic.py @@ -59,6 +59,23 @@ def load_scene_data(self, data, qos=None, topic=None, payload=None): # Update the MQTT topics and payloads from the config file. self.msg_scene.load_config(data, topic, payload, qos) + # Add ourselves to the list of topics + if len(self.extra_topic_nums) > 0: + # This device has multiple scene topics for multiple buttons + data = self.base_template_data() + topics = {} + for btn in self.extra_topic_nums: + data['button'] = btn + topics[topic + "_" + str(btn)] = self.msg_scene.render_topic( + data + ) + self.rendered_topic_map.update(topics) + else: + # Add ourselves to the list of topics + self.rendered_topic_map[topic] = self.msg_scene.render_topic( + self.base_template_data() + ) + #----------------------------------------------------------------------- def scene_subscribe(self, link, qos, group=None): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/topic/SetTopic.py b/insteon_mqtt/mqtt/topic/SetTopic.py index 5e3132f3..dbb6caeb 100644 --- a/insteon_mqtt/mqtt/topic/SetTopic.py +++ b/insteon_mqtt/mqtt/topic/SetTopic.py @@ -56,6 +56,23 @@ def load_set_data(self, data, qos=None, topic=None, payload=None): # Update the MQTT topics and payloads from the config file. self.msg_set.load_config(data, topic, payload, qos) + # Add ourselves to the list of topics + if len(self.extra_topic_nums) > 0: + # This device has multiple set topics for multiple buttons + data = self.base_template_data() + topics = {} + for btn in self.extra_topic_nums: + data['button'] = btn + topics[topic + "_" + str(btn)] = self.msg_set.render_topic( + data + ) + self.rendered_topic_map.update(topics) + else: + # Add ourselves to the list of topics + self.rendered_topic_map[topic] = self.msg_set.render_topic( + self.base_template_data() + ) + #----------------------------------------------------------------------- def set_subscribe(self, link, qos, group=None): """Subscribe to any MQTT topics the object needs. diff --git a/insteon_mqtt/mqtt/topic/StateTopic.py b/insteon_mqtt/mqtt/topic/StateTopic.py index 3ea06cf0..08c5b8d5 100644 --- a/insteon_mqtt/mqtt/topic/StateTopic.py +++ b/insteon_mqtt/mqtt/topic/StateTopic.py @@ -91,6 +91,26 @@ def load_state_data(self, data, qos=None, topic=None, payload=None, if payload_1 is None: payload_1 = 'dimmer_state_payload' self.msg_state_1.load_config(data, topic_1, payload_1, qos) + # Add ourselves to the list of topics + self.rendered_topic_map[topic_1] = self.msg_state_1.render_topic( + self.base_template_data() + ) + + if len(self.extra_topic_nums) > 0: + # This device has multiple state topics for multiple buttons + data = self.base_template_data() + topics = {} + for btn in self.extra_topic_nums: + data['button'] = btn + topics[topic + "_" + str(btn)] = self.msg_state.render_topic( + data + ) + self.rendered_topic_map.update(topics) + else: + # Add ourselves to the list of topics + self.rendered_topic_map[topic] = self.msg_state.render_topic( + self.base_template_data() + ) #----------------------------------------------------------------------- def state_template_data(self, **kwargs): diff --git a/insteon_mqtt/mqtt/topic/__init__.py b/insteon_mqtt/mqtt/topic/__init__.py index 866b2230..923773d8 100644 --- a/insteon_mqtt/mqtt/topic/__init__.py +++ b/insteon_mqtt/mqtt/topic/__init__.py @@ -12,6 +12,7 @@ """ from .BaseTopic import BaseTopic +from .DiscoveryTopic import DiscoveryTopic from .ManualTopic import ManualTopic from .SceneTopic import SceneTopic from .SetTopic import SetTopic diff --git a/tests/cmd_line/test_modem.py b/tests/cmd_line/test_modem.py index 4465ba89..93988eab 100644 --- a/tests/cmd_line/test_modem.py +++ b/tests/cmd_line/test_modem.py @@ -18,7 +18,6 @@ def check_call(self, func, args, config, topic, cmd): assert call[1] == topic assert call[2]["cmd"] == cmd assert call[2]["force"] == args.force - assert call[2]["battery"] == args.battery assert call[3] == args.quiet #----------------------------------------------------------------------- @@ -26,7 +25,7 @@ def test_refresh_all(self, mocker): mocker.patch('insteon_mqtt.cmd_line.util.send') IM.cmd_line.util.send.return_value = {"status" : 10} - args = Data(topic="cmd_topic", force=False, quiet=True, battery=True) + args = Data(topic="cmd_topic", force=False, quiet=True) config = Data(a=1, b=2) r = IM.cmd_line.modem.refresh_all(args, config) diff --git a/tests/db/test_DeviceEntry.py b/tests/db/test_DeviceEntry.py index 3b11fda7..c03fcf36 100644 --- a/tests/db/test_DeviceEntry.py +++ b/tests/db/test_DeviceEntry.py @@ -80,7 +80,7 @@ def test_label(self): device = IM.device.base.Base(protocol, modem, addr, name="Awesomesauce") modem.set_linked_device(device) - assert obj.label == "12.34.ab (Awesomesauce)" + assert obj.label == "12.34.ab (awesomesauce)" #----------------------------------------------------------------------- def test_repr(self): diff --git a/tests/db/test_ModemEntry.py b/tests/db/test_ModemEntry.py index d0209393..daae117c 100644 --- a/tests/db/test_ModemEntry.py +++ b/tests/db/test_ModemEntry.py @@ -90,7 +90,7 @@ def test_label(self): device = IM.device.base.Base(protocol, modem, addr, name="Awesomesauce") modem.set_linked_device(device) - assert obj.label == "03.04.05 (Awesomesauce)" + assert obj.label == "03.04.05 (awesomesauce)" #----------------------------------------------------------------------- diff --git a/tests/device/base/test_BaseDev.py b/tests/device/base/test_BaseDev.py index 8e87f5c6..43422241 100644 --- a/tests/device/base/test_BaseDev.py +++ b/tests/device/base/test_BaseDev.py @@ -97,6 +97,14 @@ def test_with_name(self, test_device): device = Base.from_config([{"32 34 56": 'test'}], protocol, modem) assert device + def test_with_name_case(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[0].name == 'test' + assert device[0].name_user_case == 'tEst' + def test_load_config_extra_good(self, test_device, caplog): protocol = test_device.protocol modem = test_device.modem diff --git a/tests/mqtt/test_BatterySensor.py b/tests/mqtt/test_BatterySensor.py index 693ab9d2..7a1bdcdb 100644 --- a/tests/mqtt/test_BatterySensor.py +++ b/tests/mqtt/test_BatterySensor.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -56,12 +57,15 @@ def test_template(self, setup): data = mdev.template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.template_data(is_on=True, is_low=False) right = {"address" : addr.hex, "name" : name, "on" : 1, "on_str" : "on", "is_low" : 0, "is_low_str" : "off"} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -92,6 +96,20 @@ def test_mqtt(self, setup): assert link.client.pub[1] == dict( topic='%s/battery' % topic, payload='on', qos=0, retain=True) + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"battery_sensor": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "battery_sensor" + assert mdev.rendered_topic_map == { + 'state_topic': 'insteon/01.02.03/state', + 'low_battery_topic': 'insteon/01.02.03/battery', + 'heartbeat_topic': 'insteon/01.02.03/heartbeat', + } + assert len(mdev.extra_topic_nums) == 0 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_Dimmer.py b/tests/mqtt/test_Dimmer.py index 6b873b53..cac7a2de 100644 --- a/tests/mqtt/test_Dimmer.py +++ b/tests/mqtt/test_Dimmer.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -68,6 +69,8 @@ def test_template(self, setup): data = mdev.base_template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(level=0x55, mode=IM.on_off.Mode.FAST, @@ -78,6 +81,7 @@ def test_template(self, setup): "level_255" : 85, "level_100" : 33, "mode" : "fast", "fast" : 1, "instant" : 0, "manual_str" : "stop", "manual" : 0, "manual_openhab" : 1} + del data['timestamp'] assert data == right data = mdev.state_template_data(level=0x00) @@ -85,12 +89,14 @@ def test_template(self, setup): "on" : 0, "on_str" : "off", "reason" : "", "level_255" : 0, "level_100" : 0, "mode" : "normal", "fast" : 0, "instant" : 0} + del data['timestamp'] assert data == right data = mdev.state_template_data(manual=IM.on_off.Manual.UP, reason="foo") right = {"address" : addr.hex, "name" : name, "reason" : "foo", "manual_str" : "up", "manual" : 1, "manual_openhab" : 2} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -120,6 +126,22 @@ def test_mqtt(self, setup): dev.signal_manual.emit(dev, manual=IM.on_off.Manual.STOP) assert len(link.client.pub) == 0 + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"dimmer": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "dimmer" + assert mdev.rendered_topic_map == { + 'manual_state_topic': None, + 'on_off_topic': 'insteon/01.02.03/set', + 'scene_topic': 'insteon/01.02.03/scene', + 'state_topic': 'insteon/01.02.03/state', + 'level_topic': 'insteon/01.02.03/level' + } + assert len(mdev.extra_topic_nums) == 0 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_EZIO4O.py b/tests/mqtt/test_EZIO4O.py index 5235404f..c326b332 100644 --- a/tests/mqtt/test_EZIO4O.py +++ b/tests/mqtt/test_EZIO4O.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -81,6 +82,8 @@ def test_template(self, setup): data = mdev.base_template_data() right = {"address": addr.hex, "name": name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data( @@ -97,6 +100,7 @@ def test_template(self, setup): "fast": 1, "instant": 0, } + del data['timestamp'] assert data == right data = mdev.state_template_data(is_on=False, button=2) @@ -111,6 +115,7 @@ def test_template(self, setup): "fast": 0, "instant": 0, } + del data['timestamp'] assert data == right data = mdev.state_template_data(is_on=False, button=3) @@ -125,6 +130,7 @@ def test_template(self, setup): "fast": 0, "instant": 0, } + del data['timestamp'] assert data == right data = mdev.state_template_data(is_on=False, button=4) @@ -139,6 +145,7 @@ def test_template(self, setup): "fast": 0, "instant": 0, } + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -169,6 +176,25 @@ def test_mqtt(self, setup): ) link.client.clear() + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"ezio4o": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "ezio4o" + assert mdev.rendered_topic_map == { + 'on_off_topic_1': 'insteon/01.02.03/set/1', + 'on_off_topic_2': 'insteon/01.02.03/set/2', + 'on_off_topic_3': 'insteon/01.02.03/set/3', + 'on_off_topic_4': 'insteon/01.02.03/set/4', + 'state_topic_1': 'insteon/01.02.03/state/1', + 'state_topic_2': 'insteon/01.02.03/state/2', + 'state_topic_3': 'insteon/01.02.03/state/3', + 'state_topic_4': 'insteon/01.02.03/state/4' + } + assert len(mdev.extra_topic_nums) == 4 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(["mdev", "dev", "link"]) diff --git a/tests/mqtt/test_FanLinc.py b/tests/mqtt/test_FanLinc.py index e1e521e2..2d7c2bb5 100644 --- a/tests/mqtt/test_FanLinc.py +++ b/tests/mqtt/test_FanLinc.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -72,6 +73,8 @@ def test_template(self, setup): data = mdev.base_template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(level=0x55, mode=IM.on_off.Mode.FAST, @@ -82,6 +85,7 @@ def test_template(self, setup): "level_255" : 85, "level_100" : 33, "mode" : "fast", "fast" : 1, "instant" : 0, "manual_str" : "stop", "manual" : 0, "manual_openhab" : 1} + del data['timestamp'] assert data == right data = mdev.state_template_data(level=0x00) @@ -89,11 +93,13 @@ def test_template(self, setup): "on" : 0, "on_str" : "off", "reason" : "", "level_255" : 0, "level_100" : 0, "mode" : "normal", "fast" : 0, "instant" : 0} + del data['timestamp'] assert data == right data = mdev.state_template_data(manual=IM.on_off.Manual.UP) right = {"address" : addr.hex, "name" : name, "reason" : "", "manual_str" : "up", "manual" : 1, "manual_openhab" : 2} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -102,30 +108,36 @@ def test_fan_template(self, setup): data = mdev.fan_template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.fan_template_data(level=dev.Speed.OFF, reason="hello") right = {"address" : addr.hex, "name" : name, "on" : 0, "on_str" : "off", "reason" : "hello", "level" : 0, "level_str" : 'off'} + del data['timestamp'] assert data == right data = mdev.fan_template_data(level=dev.Speed.LOW) right = {"address" : addr.hex, "name" : name, "on" : 1, "on_str" : "on", "reason" : "", "level" : 1, "level_str" : 'low'} + del data['timestamp'] assert data == right data = mdev.fan_template_data(level=dev.Speed.MEDIUM) right = {"address" : addr.hex, "name" : name, "on" : 1, "on_str" : "on", "reason" : "", "level" : 2, "level_str" : 'medium'} + del data['timestamp'] assert data == right data = mdev.fan_template_data(level=dev.Speed.HIGH, reason="foo") right = {"address" : addr.hex, "name" : name, "on" : 1, "on_str" : "on", "reason" : "foo", "level" : 3, "level_str" : 'high'} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -160,6 +172,36 @@ def test_mqtt(self, setup): topic='%s/fan/state' % topic, payload='off', qos=0, retain=True) + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + # Fan doesn't have default values for fan_speed_topic or + # fan_speed_set_topic + mdev.load_config({ + "fan_linc": { + "fan_speed_set_topic": "insteon/{{address}}/fan/speed/set", + "fan_speed_topic": "insteon/{{address}}/fan/speed/state" + }, + "dimmer": { + "junk": "junk", + } + }) + assert mdev.default_discovery_cls == "fan_linc" + assert mdev.rendered_topic_map == { + 'manual_state_topic': None, + 'on_off_topic': 'insteon/01.02.03/set', + 'scene_topic': 'insteon/01.02.03/scene', + 'state_topic': 'insteon/01.02.03/state', + 'level_topic': 'insteon/01.02.03/level', + 'fan_on_off_topic': 'insteon/01.02.03/fan/set', + 'fan_speed_set_topic': 'insteon/01.02.03/fan/speed/set', + 'fan_speed_topic': 'insteon/01.02.03/fan/speed/state', + 'fan_state_topic': 'insteon/01.02.03/fan/state' + } + assert len(mdev.extra_topic_nums) == 0 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_IOLincMqtt.py b/tests/mqtt/test_IOLincMqtt.py index c9715c35..a2eb7c44 100644 --- a/tests/mqtt/test_IOLincMqtt.py +++ b/tests/mqtt/test_IOLincMqtt.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -61,6 +62,8 @@ def test_template(self, setup): data = mdev.base_template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(button=1, is_on=True) @@ -69,6 +72,7 @@ def test_template(self, setup): "fast": 0, "instant": 0, "mode": "normal", "on": 1, "on_str": "on", "reason": "", "relay_on": 0, "relay_on_str" : "off"} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=2, is_on=True) @@ -77,6 +81,7 @@ def test_template(self, setup): "fast": 0, "instant": 0, "mode": "normal", "on": 1, "on_str": "on", "reason": "", "relay_on": 1, "relay_on_str" : "on"} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=1, is_on=False) @@ -85,6 +90,7 @@ def test_template(self, setup): "fast": 0, "instant": 0, "mode": "normal", "on": 0, "on_str": "off", "reason": "", "relay_on": 0, "relay_on_str" : "off"} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=2, is_on=False) @@ -93,6 +99,7 @@ def test_template(self, setup): "fast": 0, "instant": 0, "mode": "normal", "on": 0, "on_str": "off", "reason": "", "relay_on": 0, "relay_on_str" : "off"} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -134,6 +141,22 @@ def test_mqtt(self, setup): qos=0, retain=True) link.client.clear() + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"io_linc": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "io_linc" + assert mdev.rendered_topic_map == { + 'on_off_topic': 'insteon/01.02.03/set', + 'relay_state_topic': 'insteon/01.02.03/relay', + 'sensor_state_topic': 'insteon/01.02.03/sensor', + 'state_topic': 'insteon/01.02.03/state' + } + assert len(mdev.extra_topic_nums) == 0 + + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_KeypadLinc.py b/tests/mqtt/test_KeypadLinc.py index aa8c88b5..cc8653b4 100644 --- a/tests/mqtt/test_KeypadLinc.py +++ b/tests/mqtt/test_KeypadLinc.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -107,6 +108,8 @@ def test_template(self, setup): data = mdev.base_template_data(button=5) right = {"address" : addr.hex, "name" : name, "button" : 5} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(button=3, level=255, reason="something", @@ -117,6 +120,7 @@ def test_template(self, setup): "level_255" : 255, "level_100" : 100, "mode" : "fast", "fast" : 1, "instant" : 0, "manual_str" : "stop", "manual" : 0, "manual_openhab" : 1} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=1, level=128, @@ -125,6 +129,7 @@ def test_template(self, setup): "on" : 1, "on_str" : "on", "reason" : "", "level_255" : 128, "level_100" : 50, "mode" : "instant", "fast" : 0, "instant" : 1} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=2, level=0, reason="foo") @@ -132,6 +137,7 @@ def test_template(self, setup): "on" : 0, "on_str" : "off", "reason" : "foo", "level_255" : 0, "level_100" : 0, "mode" : "normal", "fast" : 0, "instant" : 0} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=2, manual=IM.on_off.Manual.UP, @@ -139,6 +145,7 @@ def test_template(self, setup): right = {"address" : addr.hex, "name" : name, "button" : 2, "reason" : "HELLO", "manual_str" : "up", "manual" : 1, "manual_openhab" : 2} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -165,6 +172,47 @@ def test_mqtt(self, setup): dev.signal_manual.emit(dev, button=4, manual=IM.on_off.Manual.STOP) assert len(link.client.pub) == 0 + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"keypad_linc": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "keypad_linc" + assert mdev.rendered_topic_map == { + 'dimmer_level_topic': 'insteon/01.02.03/level', + 'dimmer_state_topic': 'insteon/01.02.03/state/', + 'btn_on_off_topic_1': 'insteon/01.02.03/set/1', + 'btn_on_off_topic_2': 'insteon/01.02.03/set/2', + 'btn_on_off_topic_3': 'insteon/01.02.03/set/3', + 'btn_on_off_topic_4': 'insteon/01.02.03/set/4', + 'btn_on_off_topic_5': 'insteon/01.02.03/set/5', + 'btn_on_off_topic_6': 'insteon/01.02.03/set/6', + 'btn_on_off_topic_7': 'insteon/01.02.03/set/7', + 'btn_on_off_topic_8': 'insteon/01.02.03/set/8', + 'btn_on_off_topic_9': 'insteon/01.02.03/set/9', + 'btn_scene_topic_1': 'insteon/01.02.03/scene/1', + 'btn_scene_topic_2': 'insteon/01.02.03/scene/2', + 'btn_scene_topic_3': 'insteon/01.02.03/scene/3', + 'btn_scene_topic_4': 'insteon/01.02.03/scene/4', + 'btn_scene_topic_5': 'insteon/01.02.03/scene/5', + 'btn_scene_topic_6': 'insteon/01.02.03/scene/6', + 'btn_scene_topic_7': 'insteon/01.02.03/scene/7', + 'btn_scene_topic_8': 'insteon/01.02.03/scene/8', + 'btn_scene_topic_9': 'insteon/01.02.03/scene/9', + 'btn_state_topic_1': 'insteon/01.02.03/state/1', + 'btn_state_topic_2': 'insteon/01.02.03/state/2', + 'btn_state_topic_3': 'insteon/01.02.03/state/3', + 'btn_state_topic_4': 'insteon/01.02.03/state/4', + 'btn_state_topic_5': 'insteon/01.02.03/state/5', + 'btn_state_topic_6': 'insteon/01.02.03/state/6', + 'btn_state_topic_7': 'insteon/01.02.03/state/7', + 'btn_state_topic_8': 'insteon/01.02.03/state/8', + 'btn_state_topic_9': 'insteon/01.02.03/state/9', + 'manual_state_topic': None + } + assert len(mdev.extra_topic_nums) == 9 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_KeypadLinc_sw.py b/tests/mqtt/test_KeypadLinc_sw.py index 041acb6c..2257a3db 100644 --- a/tests/mqtt/test_KeypadLinc_sw.py +++ b/tests/mqtt/test_KeypadLinc_sw.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -69,6 +70,8 @@ def test_template(self, setup): data = mdev.base_template_data(button=5) right = {"address" : addr.hex, "name" : name, "button" : 5} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(button=3, level=1, @@ -79,6 +82,7 @@ def test_template(self, setup): "level_255" : 1, "level_100" : 0, "mode" : "fast", "fast" : 1, "instant" : 0, "manual_str" : "stop", "manual" : 0, "manual_openhab" : 1} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=1, level=0) @@ -86,12 +90,14 @@ def test_template(self, setup): "on" : 0, "on_str" : "off", "reason" : "", "level_255" : 0, "level_100" : 0, "mode" : "normal", "fast" : 0, "instant" : 0} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=2, manual=IM.on_off.Manual.UP) right = {"address" : addr.hex, "name" : name, "button" : 2, "reason" : "", "manual_str" : "up", "manual" : 1, "manual_openhab" : 2} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -117,6 +123,45 @@ def test_mqtt(self, setup): dev.signal_manual.emit(dev, button=4, manual=IM.on_off.Manual.STOP) assert len(link.client.pub) == 0 + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"keypad_linc": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "keypad_linc" + assert mdev.rendered_topic_map == { + 'btn_on_off_topic_1': 'insteon/01.02.03/set/1', + 'btn_on_off_topic_2': 'insteon/01.02.03/set/2', + 'btn_on_off_topic_3': 'insteon/01.02.03/set/3', + 'btn_on_off_topic_4': 'insteon/01.02.03/set/4', + 'btn_on_off_topic_5': 'insteon/01.02.03/set/5', + 'btn_on_off_topic_6': 'insteon/01.02.03/set/6', + 'btn_on_off_topic_7': 'insteon/01.02.03/set/7', + 'btn_on_off_topic_8': 'insteon/01.02.03/set/8', + 'btn_on_off_topic_9': 'insteon/01.02.03/set/9', + 'btn_scene_topic_1': 'insteon/01.02.03/scene/1', + 'btn_scene_topic_2': 'insteon/01.02.03/scene/2', + 'btn_scene_topic_3': 'insteon/01.02.03/scene/3', + 'btn_scene_topic_4': 'insteon/01.02.03/scene/4', + 'btn_scene_topic_5': 'insteon/01.02.03/scene/5', + 'btn_scene_topic_6': 'insteon/01.02.03/scene/6', + 'btn_scene_topic_7': 'insteon/01.02.03/scene/7', + 'btn_scene_topic_8': 'insteon/01.02.03/scene/8', + 'btn_scene_topic_9': 'insteon/01.02.03/scene/9', + 'btn_state_topic_1': 'insteon/01.02.03/state/1', + 'btn_state_topic_2': 'insteon/01.02.03/state/2', + 'btn_state_topic_3': 'insteon/01.02.03/state/3', + 'btn_state_topic_4': 'insteon/01.02.03/state/4', + 'btn_state_topic_5': 'insteon/01.02.03/state/5', + 'btn_state_topic_6': 'insteon/01.02.03/state/6', + 'btn_state_topic_7': 'insteon/01.02.03/state/7', + 'btn_state_topic_8': 'insteon/01.02.03/state/8', + 'btn_state_topic_9': 'insteon/01.02.03/state/9', + 'manual_state_topic': None + } + assert len(mdev.extra_topic_nums) == 9 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_Leak.py b/tests/mqtt/test_Leak.py index b7c7207b..5a1fb037 100644 --- a/tests/mqtt/test_Leak.py +++ b/tests/mqtt/test_Leak.py @@ -57,6 +57,8 @@ def test_template(self, setup): data = mdev.template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right t0 = time.time() @@ -64,6 +66,7 @@ def test_template(self, setup): right = {"address" : addr.hex, "name" : name, "is_heartbeat" : 1, "is_heartbeat_str" : "on"} hb = data.pop('heartbeat_time') + del data['timestamp'] assert data == right pytest.approx(t0, hb, 5) @@ -73,6 +76,7 @@ def test_template(self, setup): "is_dry" : 1, "is_dry_str" : "on", "button": 2, "fast": 0, "instant": 0, "mode": 'normal', "on": 0, "on_str": 'off', "reason": ''} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -109,6 +113,22 @@ def test_mqtt(self, setup): assert m == dict(topic='%s/heartbeat' % topic, qos=0, retain=True) pytest.approx(t0, hb, 5) + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"leak": {"junk": "junk"}, + "battery_sensor" : {"junk": "junk"}}) + assert mdev.default_discovery_cls == "leak" + assert mdev.rendered_topic_map == { + 'wet_dry_topic': 'insteon/01.02.03/wet', + 'heartbeat_topic': 'insteon/01.02.03/heartbeat', + 'low_battery_topic': 'insteon/01.02.03/battery' + } + assert len(mdev.extra_topic_nums) == 0 + + #----------------------------------------------------------------------- def test_refresh_data(self, setup): # handle refresh will pass the level and not an is_on diff --git a/tests/mqtt/test_Modem.py b/tests/mqtt/test_Modem.py index 8f7f976e..52aca57d 100644 --- a/tests/mqtt/test_Modem.py +++ b/tests/mqtt/test_Modem.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +from unittest import mock import pytest import insteon_mqtt as IM import helpers as H @@ -53,11 +54,12 @@ def test_pubsub(self, setup): topic='insteon/modem/scene') #----------------------------------------------------------------------- + @mock.patch('time.time', mock.MagicMock(return_value=12345)) def test_template(self, setup): mdev, addr, name = setup.getAll(['mdev', 'addr', 'name']) data = mdev.base_template_data() - right = {"address" : addr.hex, "name" : name} + right = {"address" : addr.hex, "name" : name, "timestamp": 12345} assert data == right #----------------------------------------------------------------------- @@ -94,5 +96,93 @@ def test_input_scene(self, setup): # test error payload link.publish(topic, b'asdf', qos, False) + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, link = setup.getAll(['mdev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + # Modem with no scenes should have no discovery topics + mdev.load_config({"modem": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "modem" + assert mdev.rendered_topic_map == { + 'scene_topic': 'insteon/modem/scene' + } + assert len(mdev.extra_topic_nums) == 0 + + #----------------------------------------------------------------------- + @mock.patch('time.time', mock.MagicMock(return_value=12345)) + def test_discovery_publish(self, setup): + mdev, link = setup.getAll(['mdev', 'link']) + + # First generate our discovery template + unique_id = "{{address}}_{{scene}}" + topic = "homeassistant/switch/%s/%s/config" % ( + setup.addr.hex, + unique_id + ) + payload = """ + { + "uniq_id": "{{address}}_{{scene}}", + "name": "{%- if scene_name != "" -%} + {{scene_name}} + {%- else -%} + Modem Scene {{scene}} + {%- endif -%}", + "cmd_t": "{{scene_topic}}", + "device": {{device_info_template}}, + "payload_on": "{\"cmd\": \"on\", \"group\": \"{{scene}}\"}", + "payload_off": "{\"cmd\": \"off\", \"group\": \"{{scene}}\"}" + } + """ + mdev.disc_templates.append(IM.mqtt.MsgTemplate(topic=topic, + payload=payload, + qos=1, + retain=False)) + + # Second add some fake groups + mdev.device.db.groups[1] = ['junk'] + mdev.device.db.groups[2] = ['junk'] + mdev.device.db.groups[0x10] = ['junk'] + + # Third add a name to one of the fake groups + mdev.device.scene_map['test_name'] = 0x10 + + # Fourth, mock the publish call on MsgTemplate + mdev.disc_templates[0].publish = mock.Mock() + mocked = mdev.disc_templates[0].publish + + # Finally call publish_discovery() and test results + mdev.publish_discovery() + assert mocked.call_count == 2 + + # The expected template data + data = { + 'address': '20.30.40', + 'dev_cat': 0, + 'dev_cat_name': 'Unknown', + 'device_info_template': '', + 'engine': 'Unknown', + 'firmware': 0, + 'model_description': 'Unknown', + 'model_number': 'Unknown', + 'modem_addr': '20.30.40', + 'name': 'modem', + 'name_user_case': 'Modem', + 'scene': 0, + 'scene_name': '', + 'sub_cat': 0, + 'timestamp': 12345 + } + + + # One call should be group 2 with no name + data['scene'] = 2 + mocked.assert_any_call(mdev.mqtt, data, retain=False) + + # Other call should be group 16 with name of 'test_name' + data['scene'] = 16 + data['scene_name'] = 'test_name' + mocked.assert_any_call(mdev.mqtt, data, retain=False) + #=========================================================================== diff --git a/tests/mqtt/test_Motion.py b/tests/mqtt/test_Motion.py index ca5d3ca7..de95eebb 100644 --- a/tests/mqtt/test_Motion.py +++ b/tests/mqtt/test_Motion.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -56,16 +57,20 @@ def test_template(self, setup): data = mdev.template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.template_data(is_on=True, is_low=False) right = {"address" : addr.hex, "name" : name, "on" : 1, "on_str" : "on", "is_low" : 0, "is_low_str" : "off"} + del data['timestamp'] assert data == right data = mdev.template_data_motion() right = {"address" : addr.hex, "name" : name} + del data['timestamp'] assert data == right data = mdev.template_data_motion(is_dawn=True) @@ -73,6 +78,7 @@ def test_template(self, setup): "is_dawn" : 1, "is_dawn_str" : "on", "is_dusk" : 0, "is_dusk_str" : "off", "state": "dawn"} + del data['timestamp'] assert data == right data = mdev.template_data_motion(is_dawn=False) @@ -80,6 +86,7 @@ def test_template(self, setup): "is_dawn" : 0, "is_dawn_str" : "off", "is_dusk" : 1, "is_dusk_str" : "on", "state": "dusk"} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -111,6 +118,22 @@ def test_mqtt(self, setup): assert link.client.pub[1] == dict( topic='%s/battery' % topic, payload='on', qos=0, retain=True) + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"motion": {"junk": "junk"}, + "battery_sensor" : {"junk": "junk"}}) + assert mdev.default_discovery_cls == "motion" + assert mdev.rendered_topic_map == { + 'dawn_dusk_topic': 'insteon/01.02.03/dawn', + 'state_topic': 'insteon/01.02.03/state', + 'heartbeat_topic': 'insteon/01.02.03/heartbeat', + 'low_battery_topic': 'insteon/01.02.03/battery' + } + assert len(mdev.extra_topic_nums) == 0 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_Mqtt.py b/tests/mqtt/test_Mqtt.py new file mode 100644 index 00000000..eec8979a --- /dev/null +++ b/tests/mqtt/test_Mqtt.py @@ -0,0 +1,103 @@ +#=========================================================================== +# +# Tests for: insteon_mqtt/mqtt/Mqtt.py +# +# pylint: disable=redefined-outer-name +#=========================================================================== +import logging +from unittest import mock +import pytest +import insteon_mqtt as IM +import helpers as H + +# Create our MQTT object to test as well as the linked Insteon object and a +# mocked MQTT client to publish to. +@pytest.fixture +def setup(mock_paho_mqtt): + link = IM.network.Mqtt() + mqttModem = H.mqtt.MockModem() + mqtt = IM.mqtt.Mqtt(link, mqttModem) + + return H.Data(mqtt=mqtt, mqttModem=mqttModem, link=link) + +@pytest.fixture +def config(): + # The minimum config required for MQTT + config = {"broker": "127.0.0.2", + "port": "12345", + "cmd_topic": "insteon/command"} + return config + +#=========================================================================== +class Test_Mqtt: + #----------------------------------------------------------------------- + def test_disabled_discovery(self, setup, caplog, config): + mqtt = setup.get('mqtt') + + with caplog.at_level(logging.DEBUG): + # No config + mqtt.load_config(config) + assert 'Discovery disabled' in caplog.text + + # Config is False + caplog.clear() + config['enable_discovery'] = False + mqtt.load_config(config) + assert 'Discovery disabled' in caplog.text + + # Config is True + caplog.clear() + config['enable_discovery'] = True + mqtt.load_config(config) + assert 'Discovery disabled' not in caplog.text + + #----------------------------------------------------------------------- + def test_handle_ha_status(self, setup, caplog): + mqtt = setup.get('mqtt') + mqtt.devices = {"test_dev": "test_dev_value"} + + message = MockMqttMessage("fake_topic", "online") + with mock.patch.object(mqtt, '_publish_device_discovery') as mocked: + mqtt.handle_ha_status(None, None, message) + mocked.assert_called_once_with("test_dev_value") + + message = MockMqttMessage("fake_topic", "offline") + with mock.patch.object(mqtt, '_publish_device_discovery') as mocked: + mqtt.handle_ha_status(None, None, message) + mocked.assert_not_called() + + message = MockMqttMessage("fake_topic", "bad_thing") + caplog.clear() + with caplog.at_level(logging.WARNING): + mqtt.handle_ha_status(None, None, message) + assert 'Unexpected HomeAssistant status message' in caplog.text + + #----------------------------------------------------------------------- + def test_publish(self, setup, caplog): + mqtt = setup.get('mqtt') + + mqtt.discovery_enabled = False + device = MockDiscovery() + with mock.patch.object(device, 'publish_discovery') as mocked: + mqtt._publish_device_discovery(device) + mocked.assert_not_called() + + mqtt.discovery_enabled = True + device = MockDiscovery() + with mock.patch.object(device, 'publish_discovery') as mocked: + mqtt._publish_device_discovery(device) + mocked.assert_called_once() + +class MockMqttMessage(): + """MockMqttMessage, generates a mocked paho mqtt message""" + def __init__(self, topic, payload): + self.topic = topic + self.payload = payload.encode(encoding='UTF-8') + +class MockDiscovery(): + """MockDiscovery, simulate a device with discovery""" + def __init__(self): + pass + + def publish_discovery(self): + pass diff --git a/tests/mqtt/test_Outlet.py b/tests/mqtt/test_Outlet.py index 0aafe50c..312f7df4 100644 --- a/tests/mqtt/test_Outlet.py +++ b/tests/mqtt/test_Outlet.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -64,6 +65,8 @@ def test_template(self, setup): data = mdev.base_template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(is_on=True, button=1, reason="something", @@ -71,12 +74,14 @@ def test_template(self, setup): right = {"address" : addr.hex, "name" : name, "button" : 1, "on" : 1, "on_str" : "on", "reason" : "something", "mode" : "fast", "fast" : 1, "instant" : 0} + del data['timestamp'] assert data == right data = mdev.state_template_data(is_on=False, button=2) right = {"address" : addr.hex, "name" : name, "button" : 2, "on" : 0, "on_str" : "off", "reason" : "", "mode" : "normal", "fast" : 0, "instant" : 0} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -97,6 +102,21 @@ def test_mqtt(self, setup): topic='%s/state/2' % topic, payload='off', qos=0, retain=True) link.client.clear() + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"outlet": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "outlet" + assert mdev.rendered_topic_map == { + 'on_off_topic_1': 'insteon/01.02.03/set/1', + 'on_off_topic_2': 'insteon/01.02.03/set/2', + 'state_topic_1': 'insteon/01.02.03/state/1', + 'state_topic_2': 'insteon/01.02.03/state/2' + } + assert len(mdev.extra_topic_nums) == 2 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_Remote.py b/tests/mqtt/test_Remote.py index 2fe343ed..572289e6 100644 --- a/tests/mqtt/test_Remote.py +++ b/tests/mqtt/test_Remote.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -57,6 +58,8 @@ def test_template(self, setup): data = mdev.base_template_data(button=3) right = {"address" : addr.hex, "name" : name, "button" : 3} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(button=4, is_on=True, @@ -67,6 +70,7 @@ def test_template(self, setup): "mode" : "fast", "fast" : 1, "instant" : 0, "manual_str" : "stop", "manual" : 0, "manual_openhab" : 1, "reason": ''} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=4, is_on=False) @@ -74,12 +78,14 @@ def test_template(self, setup): "on" : 0, "on_str" : "off", "mode" : "normal", "fast" : 0, "instant" : 0, "reason": ''} + del data['timestamp'] assert data == right data = mdev.state_template_data(button=5, manual=IM.on_off.Manual.UP) right = {"address" : addr.hex, "name" : name, "button" : 5, "manual_str" : "up", "manual" : 1, "manual_openhab" : 2, "reason": ''} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -105,6 +111,29 @@ def test_mqtt(self, setup): dev.signal_manual.emit(dev, button=1, manual=IM.on_off.Manual.STOP) assert len(link.client.pub) == 0 + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"remote": {"junk": "junk"}, + "battery_sensor" : {"junk": "junk"}}) + assert mdev.default_discovery_cls == "remote" + assert mdev.rendered_topic_map == { + 'heartbeat_topic': 'insteon/01.02.03/heartbeat', + 'low_battery_topic': 'insteon/01.02.03/battery', + 'manual_state_topic': None, + 'state_topic_1': 'insteon/01.02.03/state/1', + 'state_topic_2': 'insteon/01.02.03/state/2', + 'state_topic_3': 'insteon/01.02.03/state/3', + 'state_topic_4': 'insteon/01.02.03/state/4', + 'state_topic_5': 'insteon/01.02.03/state/5', + 'state_topic_6': 'insteon/01.02.03/state/6', + 'state_topic_7': 'insteon/01.02.03/state/7', + 'state_topic_8': 'insteon/01.02.03/state/8' + } + assert len(mdev.extra_topic_nums) == 8 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_SmokeBridge.py b/tests/mqtt/test_SmokeBridge.py index a993461c..b7831891 100644 --- a/tests/mqtt/test_SmokeBridge.py +++ b/tests/mqtt/test_SmokeBridge.py @@ -83,6 +83,21 @@ def test_mqtt(self, setup): topic='%s/co' % topic, payload='off', qos=0, retain=True) link.client.clear() + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"smoke_bridge": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "smoke_bridge" + assert mdev.rendered_topic_map == { + 'battery_topic': 'insteon/01.02.03/battery', + 'co_topic': 'insteon/01.02.03/co', + 'error_topic': 'insteon/01.02.03/error', + 'smoke_topic': 'insteon/01.02.03/smoke' + } + assert len(mdev.extra_topic_nums) == 0 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_Switch.py b/tests/mqtt/test_Switch.py index 7afbb671..477f495d 100644 --- a/tests/mqtt/test_Switch.py +++ b/tests/mqtt/test_Switch.py @@ -4,6 +4,7 @@ # # pylint: disable=redefined-outer-name #=========================================================================== +import time import pytest import insteon_mqtt as IM import helpers as H @@ -64,6 +65,8 @@ def test_template(self, setup): data = mdev.base_template_data() right = {"address" : addr.hex, "name" : name} + assert data['timestamp'] - time.time() <= 1 + del data['timestamp'] assert data == right data = mdev.state_template_data(is_on=True, mode=IM.on_off.Mode.FAST, @@ -73,18 +76,21 @@ def test_template(self, setup): "on" : 1, "on_str" : "on", "reason" : "something", "mode" : "fast", "fast" : 1, "instant" : 0, "manual_str" : "stop", "manual" : 0, "manual_openhab" : 1} + del data['timestamp'] assert data == right data = mdev.state_template_data(is_on=False) right = {"address" : addr.hex, "name" : name, "reason" : "", "on" : 0, "on_str" : "off", "mode" : "normal", "fast" : 0, "instant" : 0} + del data['timestamp'] assert data == right data = mdev.state_template_data(manual=IM.on_off.Manual.UP, reason="foo") right = {"address" : addr.hex, "name" : name, "reason" : "foo", "manual_str" : "up", "manual" : 1, "manual_openhab" : 2} + del data['timestamp'] assert data == right #----------------------------------------------------------------------- @@ -110,6 +116,21 @@ def test_mqtt(self, setup): dev.signal_manual.emit(dev, manual=IM.on_off.Manual.STOP) assert len(link.client.pub) == 0 + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"switch": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "switch" + assert mdev.rendered_topic_map == { + 'manual_state_topic': None, + 'on_off_topic': 'insteon/01.02.03/set', + 'scene_topic': 'insteon/01.02.03/scene', + 'state_topic': 'insteon/01.02.03/state' + } + assert len(mdev.extra_topic_nums) == 0 + #----------------------------------------------------------------------- def test_config(self, setup): mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) diff --git a/tests/mqtt/test_ThermostatMqtt.py b/tests/mqtt/test_ThermostatMqtt.py index b2600324..48adb9b8 100644 --- a/tests/mqtt/test_ThermostatMqtt.py +++ b/tests/mqtt/test_ThermostatMqtt.py @@ -5,11 +5,29 @@ # pylint: disable=protected-access, too-many-statements #=========================================================================== import enum +import pytest +import helpers as H import insteon_mqtt as IM from insteon_mqtt.Signal import Signal from insteon_mqtt.mqtt.MsgTemplate import MsgTemplate +@pytest.fixture +def setup(mock_paho_mqtt, tmpdir): + proto = H.main.MockProtocol() + modem = H.main.MockModem(tmpdir) + addr = IM.Address(1, 2, 3) + name = "device name" + dev = IM.device.Thermostat(proto, modem, addr, name, None) + + link = IM.network.Mqtt() + mqttModem = H.mqtt.MockModem() + mqtt = IM.mqtt.Mqtt(link, mqttModem) + mdev = IM.mqtt.Thermostat(mqtt, dev) + + return H.Data(addr=addr, name=name, dev=dev, mdev=mdev, link=link, + proto=proto) + class Test_ThermostatMqtt: def test_basic(self): mqtt = MockMqtt() @@ -171,6 +189,29 @@ def test_basic(self): thermo._input_cool_setpoint(None, None, message) assert round(device.cool_sp, 1) == 30 + #----------------------------------------------------------------------- + def test_discovery(self, setup): + mdev, dev, link = setup.getAll(['mdev', 'dev', 'link']) + topic = "insteon/%s" % setup.addr.hex + + mdev.load_config({"thermostat": {"junk": "junk"}}) + assert mdev.default_discovery_cls == "thermostat" + assert mdev.rendered_topic_map == { + 'ambient_temp_topic': 'insteon/01.02.03/ambient_temp', + 'cool_sp_command_topic': 'insteon/01.02.03/cool_sp_command', + 'cool_sp_state_topic': 'insteon/01.02.03/cool_sp_state', + 'energy_state_topic': 'insteon/01.02.03/energy_state', + 'fan_command_topic': 'insteon/01.02.03/fan_command', + 'fan_state_topic': 'insteon/01.02.03/fan_state', + 'heat_sp_command_topic': 'insteon/01.02.03/heat_sp_command', + 'heat_sp_state_topic': 'insteon/01.02.03/heat_sp_state', + 'hold_state_topic': 'insteon/01.02.03/hold_state', + 'humid_state_topic': 'insteon/01.02.03/humid_state', + 'mode_command_topic': 'insteon/01.02.03/mode_command', + 'mode_state_topic': 'insteon/01.02.03/mode_state', + 'status_state_topic': 'insteon/01.02.03/status_state' + } + assert len(mdev.extra_topic_nums) == 0 #=========================================================================== class MockMqtt: diff --git a/tests/mqtt/topic/test_DiscoveryTopic.py b/tests/mqtt/topic/test_DiscoveryTopic.py new file mode 100644 index 00000000..5f503e24 --- /dev/null +++ b/tests/mqtt/topic/test_DiscoveryTopic.py @@ -0,0 +1,202 @@ +#=========================================================================== +# +# Tests for: insteon_mqtt/mqtt/topic/DiscoveryTopic.py +# +# pylint: disable=redefined-outer-name +#=========================================================================== +from unittest import mock +import pytest +import insteon_mqtt as IM +import helpers as H + +# Create the base mqtt object +@pytest.fixture +def discovery(mock_paho_mqtt, tmpdir): + link = IM.network.Mqtt() + mqttModem = H.mqtt.MockModem() + mqtt = IM.mqtt.Mqtt(link, mqttModem) + # set the default topic base + mqtt.discovery_topic_base = "homeassistant" + + protocol = H.main.MockProtocol() + modem = H.main.MockModem(tmpdir) + addr = IM.Address(0x11, 0x22, 0x33) + device = IM.device.Switch(protocol, modem, addr) + discovery = IM.mqtt.topic.DiscoveryTopic(mqtt, device) + + return discovery + +#=========================================================================== +class Test_DiscoveryTopic: + #----------------------------------------------------------------------- + def test_load_discovery_data(self, discovery, caplog): + # This also fully tests _get_unique_id() + discovery.mqtt.discovery_enabled = True + + # test lack of discovery class + config = {} + discovery.load_discovery_data(config) + assert 'Unable to find discovery class' in caplog.text + caplog.clear() + + # test lack of entities defined + config['fake_dev'] = {} + discovery.device.config_extra['discovery_class'] = 'fake_dev' + discovery.load_discovery_data(config) + assert 'No discovery_entities defined' in caplog.text + caplog.clear() + + # test lack of component + config['fake_dev'] = {'discovery_entities': [{}]} + discovery.load_discovery_data(config) + assert 'No component specified in discovery entity' in caplog.text + caplog.clear() + + # Override data at this point + discovery.discovery_template_data = mock.Mock(return_value={}) + + # test lack of unique id + config['fake_dev'] = {'discovery_entities': [{ + "component": "switch", + "config": "{}" + }]} + discovery.load_discovery_data(config) + assert 'Error getting unique_id, skipping entry' in caplog.text + caplog.clear() + + # test with unique_id + config['fake_dev'] = {'discovery_entities': [{ + "component": "switch", + "config": '{"unique_id": "unique"}' + }]} + discovery.load_discovery_data(config) + expected_topic = "homeassistant/switch/11_22_33/unique/config" + assert discovery.disc_templates[0].topic_str == expected_topic + discovery.disc_templates = [] + + # test with uniq_id + config['fake_dev'] = {'discovery_entities': [{ + "component": "switch", + "config": '{"uniq_id": "unique2"}' + }]} + discovery.load_discovery_data(config) + expected_topic = "homeassistant/switch/11_22_33/unique2/config" + assert discovery.disc_templates[0].topic_str == expected_topic + discovery.disc_templates = [] + + # test bad json + config['fake_dev'] = {'discovery_entities': [{ + "component": "switch", + "config": "{'no_single': 'quotes'}" + }]} + discovery.load_discovery_data(config) + expected_topic = "homeassistant/switch/11_22_33/unique2/config" + assert 'Error parsing config as json' in caplog.text + caplog.clear() + + # test bad template + config['fake_dev'] = {'discovery_entities': [{ + "component": "switch", + "config": "{% if bad_format = 1 %}" + }]} + discovery.load_discovery_data(config) + expected_topic = "homeassistant/switch/11_22_33/unique2/config" + assert 'Error rendering config template' in caplog.text + caplog.clear() + + #----------------------------------------------------------------------- + def test_template_data(self, discovery, caplog): + # Test default values + data = discovery.discovery_template_data() + assert data['address'] == "11.22.33" + assert data['name'] == "11.22.33" + assert data['name_user_case'] == "11.22.33" + assert data['engine'] == "Unknown" + assert data['model_number'] == 'Unknown' + assert data['model_description'] == 'Unknown' + assert data['dev_cat_name'] == 'Unknown' + assert data['dev_cat'] == 0 + assert data['sub_cat'] == 0 + assert data['firmware'] == 0 + assert data['modem_addr'] == "20.30.40" + assert data['device_info_template'] == "" + + # Test with actual values + discovery.device.name = "test device" + discovery.device.name_user_case = "Test Device" + discovery.device.db.engine = 2 + discovery.device.db.desc = IM.catalog.find(0x02, 0x2a) + discovery.device.db.firmware = 0x45 + data = discovery.discovery_template_data() + assert data['name'] == "test device" + assert data['name_user_case'] == "Test Device" + assert data['engine'] == "i2cs" + assert data['model_number'] == '2477S' + assert data['model_description'] == 'SwitchLinc Relay (Dual-Band)' + assert data['dev_cat_name'] == 'SWITCHED_LIGHTING' + assert data['dev_cat'] == 0x02 + assert data['sub_cat'] == 0x2a + assert data['firmware'] == 0x45 + assert data['modem_addr'] == "20.30.40" + assert data['device_info_template'] == "" + + # test device info template + discovery.mqtt.device_info_template = """ + { + "ids": "{{address}}", + "mf": "Insteon", + "mdl": "{%- if model_number != 'Unknown' -%} + {{model_number}} - {{model_description}} + {%- elif dev_cat_name != 'Unknown' -%} + {{dev_cat_name}} - 0x{{'%0x' % sub_cat|int }} + {%- elif dev_cat == 0 and sub_cat == 0 -%} + No Info + {%- else -%} + 0x{{'%0x' % dev_cat|int }} - 0x{{'%0x' % sub_cat|int }} + {%- endif -%}", + "sw": "0x{{'%0x' % firmware|int }} - {{engine}}", + "name": "{{name_user_case}}", + "via_device": "{{modem_addr}}" + } + """ + data = discovery.discovery_template_data() + assert data['device_info_template'] == """ + { + "ids": "11.22.33", + "mf": "Insteon", + "mdl": "2477S - SwitchLinc Relay (Dual-Band)", + "sw": "0x45 - i2cs", + "name": "Test Device", + "via_device": "20.30.40" + } + """ + + # test bad device info template + discovery.mqtt.device_info_template = " {% if bad = 1 %}" + data = discovery.discovery_template_data() + assert 'Error rendering device_info_template' in caplog.text + caplog.clear() + + #----------------------------------------------------------------------- + @mock.patch('time.time', mock.MagicMock(return_value=12345)) + def test_publish(self, discovery, caplog): + discovery.disc_templates.append(mock.Mock()) + discovery.publish_discovery() + data = {'address': '11.22.33', + 'name': '11.22.33', + 'name_user_case': '11.22.33', + 'engine': 'Unknown', + 'model_number': 'Unknown', + 'model_description': 'Unknown', + 'dev_cat': 0, + 'dev_cat_name': 'Unknown', + 'sub_cat': 0, + 'firmware': 0, + 'modem_addr': '20.30.40', + 'device_info_template': '', + 'timestamp': 12345} + discovery.disc_templates[0].publish.assert_called_once_with( + discovery.mqtt, + data, + retain=False + ) diff --git a/tests/test_Scenes.py b/tests/test_Scenes.py index a9fb6d8a..febe4e20 100644 --- a/tests/test_Scenes.py +++ b/tests/test_Scenes.py @@ -251,7 +251,7 @@ def test_Dimmer_scenes_same_ramp_rate(self): assert len(scenes.entries) == 1 assert len(scenes.data[0]['controllers']) == 2 assert len(scenes.data[0]['responders']) == 1 - assert scenes.data[0]['responders'][0]['Dimmer']['ramp_rate'] == 19 + assert scenes.data[0]['responders'][0]['dimmer']['ramp_rate'] == 19 def test_Dimmer_scenes_different_ramp_rates(self): modem = MockModem() @@ -325,7 +325,7 @@ def test_FanLinc_scenes_same_ramp_rate(self): assert len(scenes.entries) == 1 assert len(scenes.data[0]['controllers']) == 2 assert len(scenes.data[0]['responders']) == 1 - assert scenes.data[0]['responders'][0]['FanLinc']['ramp_rate'] == 19 + assert scenes.data[0]['responders'][0]['fanlinc']['ramp_rate'] == 19 def test_FanLinc_scenes_different_ramp_rates(self): modem = MockModem() @@ -400,7 +400,7 @@ def test_KeypadLinc_scenes_same_ramp_rate(self): assert len(scenes.entries) == 1 assert len(scenes.data[0]['controllers']) == 2 assert len(scenes.data[0]['responders']) == 1 - assert scenes.data[0]['responders'][0]['KeypadLinc']['ramp_rate'] == 19 + assert scenes.data[0]['responders'][0]['keypadlinc']['ramp_rate'] == 19 def test_KeypadLinc_scenes_different_ramp_rates(self): modem = MockModem() @@ -573,8 +573,8 @@ def test_mini_remote_button_config_with_data3(self): assert scenes.entries[0].controllers[0].group == 2 assert scenes.entries[0].controllers[0].link_data == [3, 0, 2] assert scenes.entries[0].controllers[0].style == 0 - assert scenes.data[0]['controllers'][0]['Remote']['group'] == 2 - assert scenes.data[0]['controllers'][0]['Remote']['data_3'] == 2 + assert scenes.data[0]['controllers'][0]['remote']['group'] == 2 + assert scenes.data[0]['controllers'][0]['remote']['data_3'] == 2 def test_foreign_hub_keypad_button_backlights_scene(self): modem = MockModem() diff --git a/tests/util/helpers/main.py b/tests/util/helpers/main.py index d16c85fb..1c9fe5ce 100644 --- a/tests/util/helpers/main.py +++ b/tests/util/helpers/main.py @@ -78,6 +78,7 @@ def __init__(self, protocol, modem, address, name=None): self.modem = modem self.addr = IM.Address(address) self.name = name + self.name_user_case = name def send(self, msg, msg_handler, high_priority=False, after=None): self.protocol.send(msg, msg_handler, high_priority, after)