Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Huge ISY994 platform cleanup, fixes support for 5.0.10 firmware #11243

Merged
merged 10 commits into from
Dec 26, 2017

Conversation

OverloadUT
Copy link
Contributor

@OverloadUT OverloadUT commented Dec 20, 2017

Description:

Breaking Change Notes

  • The hidden_string feature has been removed from the isy994 component. Previously, this allowed entities to be "hidden" in Home Assistant if a configured string was present in an ISY device's name or folder path. This was removed because hiding devices is now done via the customization feature.
    • Note however, that this feature was replaced by a new ignore_string config option, which will now cause Home Assistant to completely ignore devices with the matching string so that they will not be imported as a Home Assistant device at all. This can be helpful if you have nodes in the ISY that aren't useful at all in Hass (IR transmitter nodes are a good example.)

Initial Problem Statement

The 5.0.10 update to the ISY994 firmware completely broke Home Assistant's integration. They are deprecating the uom values as useful attributes, and developers are encouraged to use the NodeDefs instead.

Trying to add this support in Hass revealed how much the ISY994 component and platforms were in need of a refactor - many things about how it works would not pass a code review today. During the refactor I found many other issues that I fixed. So this ended up being a pretty large cleanup of all of the isy994 files.

List Of Changes

  • All of the globals have been removed, and now the data that must be shared across platforms is stored in hass.data
  • The parent ISY994 component now handles categorizing all of the nodes in to the various domains. Before, this logic was handled partially by the parent component and partially by each individual domain - this required keeping all of the decentralized filtering logic in perfect sync, or risk having a single node turn in to multiple Hass entities. This is what the majority of the new code in components/isy994.py does. I organized it in a way that is hopefully easy to adjust in the future and commented as much as I could about what is going on. It is complex no matter how you shake it though, because we're supporting four entirely different ways of identifying device types, each that work with different families of devices and ISY firmware versions.
  • I completely removed the hidden_string feature. Accomplishing this behavior can be done via customize, so it didn't make sense to me to have the logic in two different places. I REPLACED the feature with ignore_string, which will now completely ignore a node that contains the string, preventing Hass entities from getting created in the first place. This is very useful to prevent a bunch of pointless nodes from getting Hass entities that just end up hidden.
  • I found several unused methods and properties in various classes that I removed.
  • The parsing of the hostname for component setup was doing a bunch of precarious string replaces. I fixed this to use the more convenient properties that were available from urlparse.
  • Removed broken logic in the fan Program component. It was setting properties that have no setters, which couldn't possibly have worked all this time.
  • Added the missing SUPPORTED_FEATURES to the fan component to indicate that it can set speed
  • Added better error handling and a log warning when an ISY994 program entity fails to initialize
  • Cleaned up a few instances of unnecessarily complicated logic paths, and other cases of unnecessary logic that is already handled by base classes
  • Added a config option to disable the import of climate nodes as Hass sensors. This allows the user to keep the climate module installed in the ISY firmware, but not have them appear in Hass.
  • Weather sensors now have spaces instead of underscores, making them much nicer to read out of the box. Not a breaking change, because the entity_id stays the same!

!! This change needs to be tested by other people with other versions of the ISY994 firmware and sets of devices. This is a very big change to the fundamental way that devices are set up! !!

Pull request in home-assistant.github.io with documentation (if applicable): (pending)

Example entry for configuration.yaml (if applicable):

isy994:
  host: http://192.168.0.6:80
  username: username
  password: pass
  sensor_string: sensor
  ignore_string: {IGNORE ME}

Checklist:

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • Local tests with tox run successfully. Your PR cannot be merged unless tests pass

# * No more globals - store on hass.data
# * Parent ISY994 component handles categorizing nodes in to Hass components, rather than each individual domain filtering all nodes themselves
# * Remove hidden string, replace with ignore string. Hidden should be done via the customize block; ignore fully prevents the node from getting a Hass entity
# * Removed a few unused methods in the ISYDevice class
# * Cleaned up the hostname parsing
# * Removed broken logic in the fan Program component. It was setting properties that have no setters
# * Added the missing SUPPORTED_FEATURES to the fan component to indicate that it can set speed
# * Added better error handling and a log warning when an ISY994 program entity fails to initialize
# * Cleaned up a few instances of unecessarily complicated logic paths, and other cases of unnecessary logic that is already handled by base classes
Copy link
Member

@MartinHjelmare MartinHjelmare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice clean up!

"""Representation of an ISY994 cover device."""

def __init__(self, node: object):
"""Initialize the ISY994 cover device."""
isy.ISYDevice.__init__(self, node)
ISYDevice.__init__(self, node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super().__init__(node)

"""Representation of an ISY994 fan device."""

def __init__(self, node) -> None:
"""Initialize the ISY994 fan device."""
isy.ISYDevice.__init__(self, node)
ISYDevice.__init__(self, node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

@@ -106,23 +102,18 @@ def __init__(self, name: str, node, actions) -> None:
ISYFanDevice.__init__(self, node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.


SUPPORTED_DOMAINS = ['binary_sensor', 'cover', 'fan', 'light', 'lock',
'sensor', 'switch']
def _check_for_node_def(node, hass: HomeAssistant,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If hass is an argument, it is usually the first argument.

try:
folder = ISY.programs[KEY_MY_PROGRAMS]['HA.' + component]
folder = programs[KEY_MY_PROGRAMS]['HA.' + domain]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use new style string formatting.

getattr(ISY.climate, attr + '_units'))
climate_attrs = dir(climate)
weather_nodes = [WeatherNode(getattr(climate, attr), attr,
getattr(climate, attr + '_units'))
for attr in climate_attrs
if attr + '_units' in climate_attrs]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

"""Representation of an ISY994 light devie."""

def __init__(self, node: object) -> None:
"""Initialize the ISY994 light device."""
isy.ISYDevice.__init__(self, node)
ISYDevice.__init__(self, node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update per above.

"""Representation of an ISY994 lock device."""

def __init__(self, node) -> None:
"""Initialize the ISY994 lock device."""
isy.ISYDevice.__init__(self, node)
ISYDevice.__init__(self, node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

@@ -319,8 +313,6 @@ def unit_of_measurement(self) -> str:
class ISYWeatherDevice(isy.ISYDevice):
"""Representation of an ISY994 weather device."""

_domain = 'sensor'

def __init__(self, node) -> None:
"""Initialize the ISY994 weather device."""
isy.ISYDevice.__init__(self, node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

"""Representation of an ISY994 switch device."""

def __init__(self, node) -> None:
"""Initialize the ISY994 switch device."""
isy.ISYDevice.__init__(self, node)
ISYDevice.__init__(self, node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

@OverloadUT
Copy link
Contributor Author

Good additional catches. All implemented!

except (KeyError, AssertionError):
pass
status = program[KEY_STATUS]
except (AttributeError, KeyError, AssertionError):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There doesn't seem to be an assertion check within this try...except.

Would it be possible to move all these try...except blocks for program out of the platforms and into the _categorize_programs function in the component? The aim would be that all programs in ISY994_PROGRAMS should be programs that should be added as entities. That would save a lot of near duplicated code. I see the block here in the binary sensor doesn't access the KEY_ACTIONS key on program. But that should be solvable in _categorize_programs by checking the domain.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree that's a much better way for this to work. I'll move it over.

Removes the need for a bunch of duplicate exception handling in each individual platform
Sensor platform was crashing when the ISY reported climate nodes. Logic has been fixed. Also added a config option to prevent climate sensors from getting imported from the ISY. Also replace the underscore from climate node names with spaces so they default to friendly names.
@OverloadUT
Copy link
Contributor Author

Alright, just pushed an update that moves all of the program building and validation to the component. Each platform is now so fresh and clean.

I also pushed some more cleanup to the weather modules. Universal Devices was kind enough to activate some paid modules for me so that I could better test some of the stuff here, and weather was indeed broken after my refactor. I also added a config option to prevent importing climate sensors, as I'm not sure why a user would want to rely on the ISY for climate data when Hass has so many of its own ways to get weather. This allows a user to keep the climate modules turned on inside the ISY but not have to hide them all if they don't want them. I also made the weather nodes have spaces instead of underscores. It's awesome that this is not a breaking change because the entity_id ends up the same!

HIDDEN_STRING = hidden_identifier
ignore_identifier = isy_config.get(CONF_IGNORE_STRING)
sensor_identifier = isy_config.get(CONF_SENSOR_STRING)
enable_climate = isy_config.get(CONF_ENABLE_CLIMATE)

if host.scheme == 'http':
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks should be moved to a validator in the config schema.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could parse and insert the port in the config in the validator too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can use vol.Url() as the first validator?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I haven't actually looked in to the config schema framework. If it can handle the URL stuff that would be much better. I'll do some digging.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MartinHjelmare Do you have an example of what you're looking for? I looked in to this, and the config option is already being passes through cv.url which in turn uses vol.Url(). This code here isn't really doing validation, but just munging the URL in to the format that the PyISY library expects (all of this could/should be done inside PyISY if it just accepted a URL rather than a host, port, and https boolean, but it doesn't.)

I could remove the final else here, because cv.url already verified that we're either http or https, but I'm not sure anything more than that makes sense.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def validate_host(config):
    host = urlparse(config[CONF_HOST])
    if host.scheme == 'http':
        https = False
        port = host.port or 80
    elif host.scheme == 'https':
        https = True
        port = host.port or 443
    config[CONF_HOST] = host
    config[CONF_PORT] = port
    config[CONF_HTTPS] = https
    return config

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, so this would be just moving it to a method that gets called from within setup, and injects in to the config Dict? It doesn't seem like this is something that happens inside the schema definition itself, right?

My concern with this is that it injects new config values in to the dict that didn't go through Voluptuous's Schema checks. A casual glance at the code (due to the new CONF consts) would also make it look like PORT and HTTPS can be provided as config options, even though they can't.

Copy link
Member

@MartinHjelmare MartinHjelmare Dec 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validator would be used in the config schema.

CONFIG_SCHEMA = vol.Schema(
    {DOMAIN: vol.Schema(vol.All({...}, validate_host))},
    extra=vol.ALLOW_EXTRA)

try:
folder = ISY.programs[KEY_MY_PROGRAMS]['HA.' + component]
folder = programs[KEY_MY_PROGRAMS]['HA.{}'.format(domain)]
except KeyError:
pass
else:
for dtype, _, node_id in folder.children:
if dtype is KEY_FOLDER:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String comparison should be done with ==.

Copy link
Member

@pvizeli pvizeli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--

@pvizeli pvizeli dismissed their stale review December 24, 2017 16:52

I'm wrong

not have a type.
"""
try:
device_type = node.type
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead to make a exception and hard interrupt the flow of your program, just check if type will be a attribute.

try:
if node.node_def_id == 'BinaryAlarm':
return True
node_uom = set(node.uom)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

try:
device_type = node.type
node_uom = set(map(str.lower, node.uom))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...



def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the ISY 994 platform."""
if ISY994_NODES not in hass.data:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always true

Copy link
Contributor Author

@OverloadUT OverloadUT Dec 24, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't setup get called multiple times if multiple isy994 blocks are specified in the config? I took this paradigm from here and I assumed that's why it was there:

https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/dyson.py#L47-L48

If I've got that wrong I can remove the check

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bootstrap setup a component only one time but the platform it could be call multiple times.

if ISY994_NODES not in hass.data:
hass.data[ISY994_NODES] = {}
for domain in SUPPORTED_DOMAINS:
if domain not in hass.data[ISY994_NODES]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always true

if domain not in hass.data[ISY994_NODES]:
hass.data[ISY994_NODES][domain] = []

if ISY994_WEATHER not in hass.data:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always true

if ISY994_WEATHER not in hass.data:
hass.data[ISY994_WEATHER] = []

if ISY994_PROGRAMS not in hass.data:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always true

if ISY994_PROGRAMS not in hass.data:
hass.data[ISY994_PROGRAMS] = {}
for domain in SUPPORTED_DOMAINS:
if domain not in hass.data[ISY994_PROGRAMS]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always true

@pvizeli pvizeli merged commit d687bc0 into home-assistant:dev Dec 26, 2017
@OverloadUT OverloadUT deleted the fix-isy994-newest-firmware branch December 28, 2017 20:12
@balloob balloob mentioned this pull request Jan 11, 2018
@home-assistant home-assistant locked and limited conversation to collaborators Mar 30, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants