Skip to content

Commit

Permalink
Merge pull request #401 from TD22057/dev
Browse files Browse the repository at this point in the history
Version 0.9.1
  • Loading branch information
krkeegan committed May 16, 2021
2 parents a94f0a2 + 6f473a2 commit cb1206d
Show file tree
Hide file tree
Showing 16 changed files with 914 additions and 14 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.9.0
current_version = 0.9.1
commit = True
tag = False

Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Revision Change History

## [0.9.1]

### Additions

- Validate the user config file before starting. This should help catch some
bugs, and provides clear concise descriptions of mistakes. ([PR 397][P397])
** Potential Breaking Change ** If you have an error in your config.yaml
file you will get an error when trying to startup after upgrading.

### Fixes

- Add support for `on` and `off` command for the fan group on fan_linc devices
Thanks @lnr0626 ([PR 399][P399],[PR 400][P400])

## [0.9.0]

### Discovery Platform!
Expand Down Expand Up @@ -712,3 +726,6 @@ will add new features.
[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
[P397]: https://github.com/TD22057/insteon-mqtt/pull/397
[P399]: https://github.com/TD22057/insteon-mqtt/pull/399
[P400]: https://github.com/TD22057/insteon-mqtt/pull/400
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ integrated into and controlled from anything that can use MQTT.

This package works well with HomeAssistant and can be easily [installed as an addon](docs/HA_Addon_Instructions.md) using the HomeAssistant Supervisor.

Version: 0.9.0 ([History](CHANGELOG.md))
Version: 0.9.1 ([History](CHANGELOG.md))

### Recent Breaking Changes

- 0.9.1 - A Yaml validation routine was added. If you have an error in your
config.yaml file, you will get an error on startup.
- 0.8.3 - 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. __Users not using HomeAssistant are unaffected.__
- 0.7.4 - IOLinc, the scene_topic has been elimited, please see the documentation
Expand Down Expand Up @@ -112,8 +114,6 @@ There is still more work to do and here are a some of my plans for
future enhancements:

- Full suite of unit tests.
- YAML input configuration validation.


# Thanks

Expand Down
2 changes: 1 addition & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "Insteon MQTT",
"description": "Creates an MQTT interface to the Insteon protocol.",
"slug": "insteon-mqtt",
"version": "0.9.0",
"version": "0.9.1",
"startup": "services",
"arch": ["amd64","armhf","aarch64","i386"],
"boot": "auto",
Expand Down
5 changes: 5 additions & 0 deletions insteon_mqtt/cmd_line/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ def parse_args(args):
def main(mqtt_converter=None):
args = parse_args(sys.argv[1:])

# Validate the configuration file
val_errors = config.validate(args.config)
if val_errors != "":
return val_errors

# Load the configuration file.
cfg = config.load(args.config)

Expand Down
223 changes: 223 additions & 0 deletions insteon_mqtt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@

#===========================================================================
import os.path
import re
import ipaddress
import yaml
from cerberus import Validator
from cerberus.errors import BasicErrorHandler
from . import device

# Configuration file input description to class map.
Expand All @@ -36,6 +40,88 @@
}


#===========================================================================
def validate(path):
"""Validates the configuration file against the defined schema
Args:
path: The file to load
Returns:
string: the failure message text or an empty string if no errors
"""
error = ""

# Check the main config file first
document = None
with open(path, "r") as f:
document = yaml.load(f, Loader)
error += validate_file(document, 'config-schema.yaml', 'configuration')

# Check the Scenes file
insteon = document.get('insteon', {})
scenes_path = insteon.get('scenes', None)
if scenes_path is not None:
with open(scenes_path, "r") as f:
document = yaml.load(f, Loader)
# This is a bit hacky, we have to move the scenes contents into a
# root key because cerberus has to have a root key
scenes = {"scenes": document}
error += validate_file(scenes, 'scenes-schema.yaml', 'scenes')

return error


#===========================================================================
def validate_file(document, schema_file, name):
""" This is used to validate a generic yaml file.
We use it to validate both the config and scenes files.
Returns:
(str): An error message string, or an empty string if no errors.
"""
basepath = os.path.dirname(__file__)
schema = None
schema_file_path = os.path.join(basepath, 'schemas', schema_file)
with open(schema_file_path, "r") as f:
schema = yaml.load(f, Loader=yaml.Loader)

v = IMValidator(schema, error_handler=MetaErrorHandler(schema=schema))
valid = v.validate(document)

if valid:
return ""
else:
return """
------- Validation Error -------
An error occured while trying to validate your %s file. Please
review the errors below and fix the error. InsteonMQTT cannot run until this
error is fixed.
""" % (name) + parse_validation_errors(v.errors)


#===========================================================================
def parse_validation_errors(errors, indent=0):
""" This creates a nice presentation of the error for the User
The error list looks a lot like the yaml document. Running it through
yaml.dump() was ok. However, doing it this way allows us to have
multiline error messages with nice indentations and such.
"""
error_msg = ""
for key in errors.keys():
error_msg += " " * indent + str(key) + ": \n"
for item in errors[key]:
if isinstance(item, dict):
error_msg += parse_validation_errors(item, indent=indent + 2)
else:
item = item.replace("\n", "\n " + " " * (indent + 2))
error_msg += " " * (indent) + "- " + str(item) + "\n"
return error_msg


#===========================================================================
def load(path):
"""Load the configuration file.
Expand Down Expand Up @@ -166,4 +252,141 @@ def rel_path(self, node):
% str(node))
raise yaml.constructor.ConstructorError(msg)


#===========================================================================
class MetaErrorHandler(BasicErrorHandler):
""" Used for adding custom fail message for a better UX
This is part of the Cerberus yaml validation schema.
When a test fails, this will search the contents of each meta keyword
in each key of the search path starting from the root. If the meta
value contains a key with the failed test name appended with "_error"
that message will be used in place of the standard message.
For example if the following regex fails it creates a custom error:
mqtt:
schema:
cmd_topic:
regex: '^[^/+][^+]*[^/+#]$'
meta:
regex_error: Custom regex error message
"""
messages = BasicErrorHandler.messages
messages[0x92] = "zero or more than one rule validate, when exactly " + \
"one is required"

def __init__(self, schema=None, tree=None):
self.schema = schema
super().__init__(tree)

def __iter__(self):
""" This is not implemented here either.
"""
raise NotImplementedError

def _format_message(self, field, error):
""" Hijack the _format_message of the base class to insert our own
messages.
"""
error_msg = self._find_meta_error(error.schema_path)
if error_msg is not None:
return error_msg
else:
return super()._format_message(field, error)

def _find_meta_error(self, error_path):
""" Gets the meta error message if there is one
This function recursively parses the search path for the meta keyword
starting at the root and working updwards, so that it always returns
the most specific meta value that it can find.
"""
schema_part = self.schema
error_msg = None
for iter in range(len(error_path)):
error_key = error_path[iter]
if isinstance(error_key, str):
schema_part = schema_part.get(error_key, None)
else:
# This is likely an int representing the position in a list
schema_part = schema_part[error_key]
if isinstance(schema_part, dict):
meta = schema_part.get('meta', None)
if meta is not None:
error_msg = self._find_error(meta, error_path, iter)
elif isinstance(schema_part, list):
continue
else:
break
return error_msg

def _find_error(self, meta, error_path, iter):
""" Gets the failed test error message if there is one
This function recursively parses the search path for the failed test
keyword starting at the base of meta and working updwards, the error
message the deepest into the error_path is returned.
"""
error_msg = None
for meta_iter in range(iter + 1, len(error_path)):
if not isinstance(meta, dict):
break
if error_path[meta_iter] + "_error" in meta:
meta = meta[error_path[meta_iter] + "_error"]
else:
break
if isinstance(meta, str):
error_msg = meta
return error_msg


#===========================================================================
class IMValidator(Validator):
""" Adds a few check_with functions to validate specific settings
"""
def _check_with_valid_ip(self, field, value):
""" Tests whether value is a valid ipv4 or ipv6 address
Uses the library ipaddress for accuracy
"""
try:
ipaddress.ip_address(value)
except ValueError:
self._error(field, "Invalid IP Address")

def _check_with_valid_insteon_addr(self, field, value):
""" Tests whether value is a valid Insteon Address for Insteon MQTT
Accepts any of the following:
- (int) in range of 0 - 0xFFFFFF
- (str) in any case:
- No seperators - AABBCC
- Space, dot or colon seperators - AA.BB.CC, AA BB CC, AA:BB:CC
"""
valid = False
# Try Integer First
try:
addr = int(value)
except ValueError:
pass
else:
if addr >= 0 and addr <= 0xFFFFFF:
valid = True

# See if valid string form
if not valid:
addr = re.search(
r"^[A-F0-9]{2}[ \.:]?[A-F0-9]{2}[ \.:]?[A-F0-9]{2}$",
str(value), flags=re.IGNORECASE
)
if addr is not None:
valid = True

if not valid:
self._error(field, "Insteon Addresses can be represented as: \n"
"aa.bb.cc, aabbcc, or aa:bb:cc")

#===========================================================================
2 changes: 1 addition & 1 deletion insteon_mqtt/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
variable throughout the code without causing a cyclic import
"""

__version__ = "0.9.0"
__version__ = "0.9.1"

#===========================================================================
Loading

0 comments on commit cb1206d

Please sign in to comment.