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

Add integration for Vallox Ventilation Units #24660

Merged
merged 5 commits into from Jun 25, 2019
Merged

Add integration for Vallox Ventilation Units #24660

merged 5 commits into from Jun 25, 2019

Conversation

andre-richter
Copy link
Contributor

@andre-richter andre-richter commented Jun 20, 2019

Description:

This PR adds initial support for Vallox ventilation units that are compatible to the vallox_websocket_api PyPi library.

The fan component code reuses parts from the Xiaomi miio fan code, which has been used as a template for this integration.

Related issue (if applicable): N/A

Pull request with documentation for home-assistant.io (if applicable): home-assistant/home-assistant.io#9668

Example entry for configuration.yaml (if applicable):

vallox:
  host: IP_ADDRESS

Checklist:

  • The code change is tested and works locally.
  • Local tests pass with tox. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist

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

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

  • The manifest file has all fields filled out correctly. Update and include derived files by running python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt by running python3 -m script.gen_requirements_all.
  • Untested files have been added to .coveragerc.

If the code does not interact with devices:

  • Tests have been added to verify that the new code works.

@andre-richter
Copy link
Contributor Author

@yozik04 FYI.

@yozik04
Copy link
Contributor

yozik04 commented Jun 20, 2019

Ohh. Nice!

SPEED_BOOST = 'Boost'
SPEED_FIREPLACE = 'Fireplace'

SPEED_LIST = [SPEED_HOME, SPEED_AWAY, SPEED_BOOST, SPEED_FIREPLACE]
Copy link
Contributor

Choose a reason for hiding this comment

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

Why you mess names? It makes hard to understand. SPEED != PROFILE. I would rename all SPEED_* -> PROFILE_*

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did it because the HA functions consuming it later have "speed" in their name. So one way or the other we'll have this indirection.

Copy link
Member

Choose a reason for hiding this comment

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

Only speeds in the base fan component are allowed for the set speed service/method and speed property.

https://github.com/home-assistant/home-assistant/blob/22d9bee41aa71e53a93527faa6fe748f32a9b70b/homeassistant/components/fan/__init__.py#L37-L40

See this architecture issue for a future possible change:
home-assistant/architecture#27

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If it is a hard requirement to only expose these speed options, then I fear this is a blocker for the Vallox ventilation unit integration as I picture it. The units are always-on units that constantly supply a whole building with fresh air, and the control interfaces expose four inherent, manufacturer-provided profiles between which the user can switch. Here's a screenshot of OEM web client:

image

Controlling your Vallox units from within HA only makes sense if the profile semantics can be kept. Can we make an exception until your linked PR lands?

Copy link
Member

Choose a reason for hiding this comment

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

It's a hard requirement. We are free to map the device modes to the home assistant speed modes, but that might not make sense in all cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

While I find it a bit stubborn to not allow this temporarily, I will transform profile selection to a service then.
Give me some days to complete this.

@yozik04
Copy link
Contributor

yozik04 commented Jun 21, 2019

I need to make my lib async as well...

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.

I've not done a full review yet. I've only commented on the main architectural dependent changes needed.

@@ -211,3 +211,34 @@ wemo_reset_filter_life:
entity_id:
description: Names of the WeMo humidifier entities (1 or more entity_ids are required).
example: 'fan.wemo_humidifier'

vallox_set_profile_fan_speed_home:
Copy link
Member

Choose a reason for hiding this comment

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

Integration specific services should be added to the integration package and registered under the integration domain.

Move these service descriptions to homeassistant/components/vallox/services.yaml and change the registration to the mentioned 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.

Will change.
Just for my own understanding: I tried to start from an existing integration, in this case xiaomi miio and wemo, where they did it that way. Was I wrong to assume this is the same case, or did these rules not apply back when they integrated it?

Copy link
Member

Choose a reason for hiding this comment

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

Those integrations are not the best examples, since they don't always follow our current standards and architecture.

Our architecture decisions have evolved and there are still some old integrations that don't follow them.

"Check for \"vallox:\" in the configuration.")
return

global SENSOR_INSTANCES
Copy link
Member

Choose a reason for hiding this comment

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

Don't use global. We can use hass.data to store instances specific to the integration platform.

hass.data[DATA_VALLOX_SENSOR_LIST] = sensor_list
await discovery.async_load_platform(hass, 'sensor', DOMAIN, {}, config)

await discovery.async_load_platform(hass, 'fan', DOMAIN, {}, config)
Copy link
Member

Choose a reason for hiding this comment

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

Use hass.async_create_task to schedule this as a task to avoid possible deadlock.

DOMAIN: vol.Schema({
vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]),
Copy link
Member

Choose a reason for hiding this comment

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

We no longer want to include config options for selecting sensor types. All available types should be included by default.

See https://github.com/home-assistant/architecture/blob/master/adr/0003-monitor-condition-and-data-selectors.md.

"requirements": [
"vallox-websocket-api==1.5.1",
"numpy==1.16.3",
"websocket-client==0.54.0"
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need to include the websocket client as a requirement? I would think that the vallox websocket api library would depend on the websocket client library.

Copy link
Member

Choose a reason for hiding this comment

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

Why include numpy? We don't seem to use that.

Copy link
Contributor

Choose a reason for hiding this comment

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

vallox-websockets-api lib uses that. But it should be pulled as lib dependency then.

@MartinHjelmare MartinHjelmare changed the title Add integration for Vallox Ventilation Units. Add integration for Vallox Ventilation Units Jun 22, 2019
@yozik04
Copy link
Contributor

yozik04 commented Jun 22, 2019

I am in progress or changing vallox-websocket-api to be async. Maybe will finish next week.

@andre-richter
Copy link
Contributor Author

andre-richter commented Jun 22, 2019

I want to refactor a bit and introduce an intermediary class that fetches and caches all read values with a single call periodically. Fan and sensor classes would then fetch those cached values.
This would reduce the current wasteful number of websocket calls.

@MartinHjelmare, which is an integration I can use as reference? Is smarty okay?

@MartinHjelmare
Copy link
Member

Yes, that's a good example. 👍

@andre-richter
Copy link
Contributor Author

Addressing the review comments resulted in a bigger refactoring. Please have a look. Might still need some tweaks here and there, but I hope I addressed all the bigger architectural concerns.

Removing numpy and websocket-client from the manifest did not work in my tests. They were not pulled in automatically when executing hass for the first time. Instead, an error message stated that loading the vallox integration did not work because those packets are missing.
Please advise on that part.

I will update the corresponding documentation PR once we agreed on the architecture.

self._profile = None
self._valid = False

async_track_time_interval(self._hass, self.async_update, SCAN_INTERVAL)
Copy link
Member

Choose a reason for hiding this comment

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

We don't want side effects in init method. Please move this outside the class or into a dedicated setup method.


def get_profile(self):
"""Return cached profile value."""
_LOGGER.debug("Returning profile.")
Copy link
Member

Choose a reason for hiding this comment

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

We don't end logging messages with period.

_LOGGER.debug("Updating Vallox state cache.")

try:
self._metric_cache = self._client.fetch_metrics()
Copy link
Member

Choose a reason for hiding this comment

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

If I'm reading the vallox library correctly, this does non asyncio I/O and we're in a coroutine here. That's not allowed. Coroutines will be executed in the event loop which should never block.

Copy link
Member

Choose a reason for hiding this comment

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

Consider switching affected methods to regular sync functions. This might mean we have to use the home assistant sync api.

The alternative is to schedule I/O on the executor thread pool with hass.async_add_executor_job. But if we need to do that a lot, the sync api is cleaner.

device = ValloxFan(hass.data[DOMAIN]['name'],
hass.data[DOMAIN]['client'],
hass.data[DOMAIN]['state_proxy'])
except KeyError:
Copy link
Member

Choose a reason for hiding this comment

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

Remove this. Instead add a guard clause at the top of the function body that checks if discovery_info is None and return if so.


async def async_setup(hass, config):
"""Set up the client and boot the platforms."""
if DOMAIN not in config:
Copy link
Member

Choose a reason for hiding this comment

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

This can't happen. There is no network discovery and we're not using config entries here yet.

self._fan_speed_boost = None

try:
self._client.set_settable_address(METRIC_KEY_MODE, int)
Copy link
Member

Choose a reason for hiding this comment

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

Please avoid side effects in init method.

self._available = False
_LOGGER.error("Error turning on: %s", io_err)
else:
_LOGGER.error("Already on.")
Copy link
Member

Choose a reason for hiding this comment

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

If we have the wrong state info we won't be able to change state. If that's a possibility, we try to avoid guards like this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I put it there to catch a call to the service fan/turn_on, where tests showed it executes even if the fan is already on.
This could for example happen if a user-automation script is erroneous.

Copy link
Member

Choose a reason for hiding this comment

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

Would it be bad for the device to call turn on if it's already on?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't know. There's no documentation that would give it away, so I did not want to take chances and cause side effects in the device when we can prevent it on the higher level.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's better to put protection close to the device if the device requires protection.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The problem is we cannot find out if the device firmware is fine with mutliple calls or if there would be unforeseen consequences if it would be spammed with those commands due to a user script gone rogue (making up a "dramatic" case here).

In the end it is just an if/else on a boolean to be on the safe side here. IMO, that's an acceptable price to pay for a little more safety?

_LOGGER.debug("Turn on: %s", speed)

# Case speed == None equals the GUI toggle switch being activated.
if speed is None:
Copy link
Member

Choose a reason for hiding this comment

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

Invert this and make it a guard clause instead. Ie return if true. Then we can outdent below.

if mode == 0:
self._state = True
else:
self._state = None
Copy link
Member

Choose a reason for hiding this comment

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

We don't want to set it to False which means off?

@MartinHjelmare
Copy link
Member

I'll look into the requirements.

@MartinHjelmare
Copy link
Member

I think the design is ok. Consider using the home assistant sync api until the library supports asyncio.

@andre-richter
Copy link
Contributor Author

andre-richter commented Jun 24, 2019

Due to lack of personal bandwidth, I went for executor_jobs instead of of changing APIs. This will also make the transition to the async version of vallox_websocket_api in the near future straight forward.

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.

I think this looks good! I'll have a look at the requirements now.

homeassistant/components/vallox/sensor.py Show resolved Hide resolved
self._client.get_profile)
self._valid = True

except IOError as io_err:
Copy link
Member

Choose a reason for hiding this comment

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

We can except OSError instead. IOError is an alias of OSError.

https://docs.python.org/3/library/exceptions.html#IOError

The following exceptions are kept for compatibility with previous versions; starting from Python 3.3, they are aliases of OSError.

Python 3.6.7 (default, Oct 22 2018, 11:32:17) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.3.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: try: 
   ...:     raise IOError 
   ...: except OSError: 
   ...:     print("Caught the IOError") 
   ...:                                                                                                                                                                                                       
Caught the IOError

In [2]:

@MartinHjelmare
Copy link
Member

I think I've found the problem with the requirements not installing:
yozik04/vallox_websocket_api#9

@yozik04
Copy link
Contributor

yozik04 commented Jun 25, 2019

PR Merged and new version created: https://pypi.org/project/vallox-websocket-api/
Use: 1.5.2

About async version of the API. For some reason aiohttp.ClientRequest does not connect to Vallox websocket... Wireshark shows exactly the same request. Response from the unit is different... Investigating.

@andre-richter
Copy link
Contributor Author

Tested and working with vallox_websocket_api 1.5.2

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.

Great!

@MartinHjelmare MartinHjelmare merged commit 236820d into home-assistant:dev Jun 25, 2019
@yozik04
Copy link
Contributor

yozik04 commented Jun 26, 2019

I have released async version of the API
vallox_websocket_api 2.0.0

Interface is exactly the same. Just add await to all called methods.

@balloob balloob mentioned this pull request Jul 17, 2019
@andre-richter
Copy link
Contributor Author

Hi @yozik04 ,

Please see the error message below from https://community.home-assistant.io/t/vallox-valloplus-350mv-partial-success-work-in-progress-on-vallox-component/52588/30

Is the response from the Vallox unit bad? Anything we can do about it? I hit this bug as well already, sometimes it helps to try multiple times.

Error during setup of component vallox
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/websockets/client.py", line 77, in read_http_response
    status_code, headers = yield from read_response(self.reader)
  File "/usr/local/lib/python3.7/site-packages/websockets/http.py", line 123, in read_response
    status_line = yield from read_line(stream)
  File "/usr/local/lib/python3.7/site-packages/websockets/http.py", line 197, in read_line
    raise ValueError("Line without CRLF")
ValueError: Line without CRLF

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/src/homeassistant/homeassistant/setup.py", line 153, in _async_setup_component
    hass, processed_config)
  File "/usr/src/homeassistant/homeassistant/components/vallox/__init__.py", line 114, in async_setup
    await state_proxy.async_update(None)
  File "/usr/src/homeassistant/homeassistant/components/vallox/__init__.py", line 163, in async_update
    self._metric_cache = await self._client.fetch_metrics()
  File "/usr/local/lib/python3.7/site-packages/vallox_websocket_api/client.py", line 235, in fetch_metrics
    result = await self._websocket_request(command=vlxDevConstants.WS_WEB_UI_COMMAND_READ_TABLES)
  File "/usr/local/lib/python3.7/site-packages/vallox_websocket_api/client.py", line 223, in _websocket_request
    async with websockets.connect("ws://%s/" % self.ip_address) as ws:
  File "/usr/local/lib/python3.7/site-packages/websockets/py35/client.py", line 2, in __aenter__
    return await self
  File "/usr/local/lib/python3.7/site-packages/websockets/py35/client.py", line 19, in __await_impl__
    extra_headers=protocol.extra_headers,
  File "/usr/local/lib/python3.7/site-packages/websockets/client.py", line 260, in handshake
    status_code, response_headers = yield from self.read_http_response()
  File "/usr/local/lib/python3.7/site-packages/websockets/client.py", line 79, in read_http_response
    raise InvalidMessage("Malformed HTTP message") from exc
websockets.exceptions.InvalidMessage: Malformed HTTP message

@MartinHjelmare
Copy link
Member

Please don't discuss in merged PRs. Open an issue instead. Thanks!

@home-assistant home-assistant locked as resolved and limited conversation to collaborators Jul 20, 2019
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.

None yet

4 participants