diff --git a/CODEOWNERS b/CODEOWNERS index 4a5b6b0d56e..ec098523744 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -305,7 +305,7 @@ homeassistant/components/ohmconnect/* @robbiet480 homeassistant/components/ombi/* @larssont homeassistant/components/omnilogic/* @oliver84 @djtimca @gentoosu homeassistant/components/onboarding/* @home-assistant/core -homeassistant/components/onewire/* @garbled1 +homeassistant/components/onewire/* @garbled1 @epenet homeassistant/components/onvif/* @hunterjm homeassistant/components/openerz/* @misialq homeassistant/components/opengarage/* @danielhiversen diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index af68135af10..23a686767b0 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -1,12 +1,18 @@ """Constants for 1-Wire component.""" +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + CONF_MOUNT_DIR = "mount_dir" CONF_NAMES = "names" +CONF_TYPE_OWFS = "OWFS" +CONF_TYPE_OWSERVER = "OWServer" +CONF_TYPE_SYSBUS = "SysBus" + DEFAULT_OWSERVER_PORT = 4304 DEFAULT_SYSBUS_MOUNT_DIR = "/sys/bus/w1/devices/" DOMAIN = "onewire" SUPPORTED_PLATFORMS = [ - "sensor", + SENSOR_DOMAIN, ] diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index f812454ae59..42ef7e54603 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -2,6 +2,6 @@ "domain": "onewire", "name": "1-Wire", "documentation": "https://www.home-assistant.io/integrations/onewire", - "requirements": ["pyownet==0.10.0.post1"], - "codeowners": ["@garbled1"] + "requirements": ["pyownet==0.10.0.post1", "pi1wire==0.1.0"], + "codeowners": ["@garbled1", "@epenet"] } diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 2ac00c814b2..4c6564b2fec 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -2,8 +2,13 @@ from glob import glob import logging import os -import time +from pi1wire import ( + InvalidCRCException, + NotFoundSensorException, + Pi1Wire, + UnsupportResponseException, +) from pyownet import protocol import voluptuous as vol @@ -23,6 +28,9 @@ from homeassistant.helpers.entity import Entity from .const import ( CONF_MOUNT_DIR, CONF_NAMES, + CONF_TYPE_OWFS, + CONF_TYPE_OWSERVER, + CONF_TYPE_SYSBUS, DEFAULT_OWSERVER_PORT, DEFAULT_SYSBUS_MOUNT_DIR, ) @@ -54,6 +62,8 @@ DEVICE_SENSORS = { "EF": {"HobbyBoard": "special"}, } +DEVICE_SUPPORT_SYSBUS = ["10", "22", "28", "3B", "42"] + # EF sensors are usually hobbyboards specialized sensors. # These can only be read by OWFS. Currently this driver only supports them # via owserver (network protocol) @@ -120,18 +130,32 @@ def hb_info_from_type(dev_type="std"): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up 1-Wire platform.""" - base_dir = config[CONF_MOUNT_DIR] - owport = config[CONF_PORT] - owhost = config.get(CONF_HOST) + entities = get_entities(config) + add_entities(entities, True) - devs = [] + +def get_entities(config): + """Get a list of entities.""" + base_dir = config[CONF_MOUNT_DIR] + owhost = config.get(CONF_HOST) + owport = config[CONF_PORT] + + # Ensure type is configured + if owhost: + conf_type = CONF_TYPE_OWSERVER + elif base_dir == DEFAULT_SYSBUS_MOUNT_DIR: + conf_type = CONF_TYPE_SYSBUS + else: + conf_type = CONF_TYPE_OWFS + + entities = [] device_names = {} if CONF_NAMES in config: if isinstance(config[CONF_NAMES], dict): device_names = config[CONF_NAMES] # We have an owserver on a remote(or local) host/port - if owhost: + if conf_type == CONF_TYPE_OWSERVER: _LOGGER.debug("Initializing using %s:%s", owhost, owport) try: owproxy = protocol.proxy(host=owhost, port=owport) @@ -166,7 +190,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensor_key = f"wetness_{s_id}" sensor_id = os.path.split(os.path.split(device)[0])[1] device_file = os.path.join(os.path.split(device)[0], sensor_value) - devs.append( + entities.append( OneWireProxy( device_names.get(sensor_id, sensor_id), device_file, @@ -176,19 +200,34 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) # We have a raw GPIO ow sensor on a Pi - elif base_dir == DEFAULT_SYSBUS_MOUNT_DIR: - _LOGGER.debug("Initializing using SysBus %s", base_dir) - for device_family in DEVICE_SENSORS: - for device_folder in glob(os.path.join(base_dir, f"{device_family}[.-]*")): - sensor_id = os.path.split(device_folder)[1] - device_file = os.path.join(device_folder, "w1_slave") - devs.append( - OneWireDirect( - device_names.get(sensor_id, sensor_id), - device_file, - "temperature", - ) + elif conf_type == CONF_TYPE_SYSBUS: + _LOGGER.debug("Initializing using SysBus") + for p1sensor in Pi1Wire().find_all_sensors(): + family = p1sensor.mac_address[:2] + sensor_id = f"{family}-{p1sensor.mac_address[2:]}" + if family not in DEVICE_SUPPORT_SYSBUS: + _LOGGER.warning( + "Ignoring unknown family (%s) of sensor found for device: %s", + family, + sensor_id, ) + continue + + device_file = f"/sys/bus/w1/devices/{sensor_id}/w1_slave" + entities.append( + OneWireDirect( + device_names.get(sensor_id, sensor_id), + device_file, + "temperature", + p1sensor, + ) + ) + if not entities: + _LOGGER.error( + "No onewire sensor found. Check if dtoverlay=w1-gpio " + "is in your /boot/config.txt. " + "Check the mount_dir parameter if it's defined" + ) # We have an owfs mounted else: @@ -204,7 +243,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_file = os.path.join( os.path.split(family_file_path)[0], sensor_value ) - devs.append( + entities.append( OneWireOWFS( device_names.get(sensor_id, sensor_id), device_file, @@ -212,15 +251,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) ) - if devs == []: - _LOGGER.error( - "No onewire sensor found. Check if dtoverlay=w1-gpio " - "is in your /boot/config.txt. " - "Check the mount_dir parameter if it's defined" - ) - return - - add_entities(devs, True) + return entities class OneWire(Entity): @@ -234,12 +265,6 @@ class OneWire(Entity): self._state = None self._value_raw = None - def _read_value_raw(self): - """Read the value as it is returned by the sensor.""" - with open(self._device_file) as ds_device_file: - lines = ds_device_file.readlines() - return lines - @property def name(self): """Return the name of the sensor.""" @@ -300,24 +325,36 @@ class OneWireProxy(OneWire): class OneWireDirect(OneWire): """Implementation of a 1-Wire sensor directly connected to RPI GPIO.""" + def __init__(self, name, device_file, sensor_type, owsensor): + """Initialize the sensor.""" + super().__init__(name, device_file, sensor_type) + self._owsensor = owsensor + def update(self): """Get the latest data from the device.""" value = None - lines = self._read_value_raw() - while lines[0].strip()[-3:] != "YES": - time.sleep(0.2) - lines = self._read_value_raw() - equals_pos = lines[1].find("t=") - if equals_pos != -1: - value_string = lines[1][equals_pos + 2 :] - value = round(float(value_string) / 1000.0, 1) - self._value_raw = float(value_string) + try: + self._value_raw = self._owsensor.get_temperature() + value = round(float(self._value_raw), 1) + except ( + FileNotFoundError, + InvalidCRCException, + NotFoundSensorException, + UnsupportResponseException, + ) as ex: + _LOGGER.warning("Cannot read from sensor %s: %s", self._device_file, ex) self._state = value class OneWireOWFS(OneWire): """Implementation of a 1-Wire sensor through owfs.""" + def _read_value_raw(self): + """Read the value as it is returned by the sensor.""" + with open(self._device_file) as ds_device_file: + lines = ds_device_file.readlines() + return lines + def update(self): """Get the latest data from the device.""" value = None diff --git a/requirements_all.txt b/requirements_all.txt index 0aa29d30307..e17aa1cb7ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,6 +1087,9 @@ pencompy==0.0.3 # homeassistant.components.unifi_direct pexpect==4.6.0 +# homeassistant.components.onewire +pi1wire==0.1.0 + # homeassistant.components.pi4ioe5v9xxxx pi4ioe5v9xxxx==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9e4227da7a..4e1e3855dcc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -516,6 +516,9 @@ pdunehd==1.3.2 # homeassistant.components.unifi_direct pexpect==4.6.0 +# homeassistant.components.onewire +pi1wire==0.1.0 + # homeassistant.components.pilight pilight==0.1.1 @@ -773,6 +776,9 @@ pyotp==2.3.0 # homeassistant.components.openweathermap pyowm==2.10.0 +# homeassistant.components.onewire +pyownet==0.10.0.post1 + # homeassistant.components.point pypoint==1.1.2 diff --git a/tests/components/onewire/test_entity_sysbus.py b/tests/components/onewire/test_entity_sysbus.py new file mode 100644 index 00000000000..4667ab641e7 --- /dev/null +++ b/tests/components/onewire/test_entity_sysbus.py @@ -0,0 +1,129 @@ +"""Tests for 1-Wire temperature sensor (device family 10, 22, 28, 3B, 42) connected on SysBus.""" +from datetime import datetime, timedelta +from unittest.mock import PropertyMock, patch + +from pi1wire import ( + InvalidCRCException, + NotFoundSensorException, + UnsupportResponseException, +) + +from homeassistant.components.onewire.const import ( + DEFAULT_OWSERVER_PORT, + DEFAULT_SYSBUS_MOUNT_DIR, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import TEMP_CELSIUS +from homeassistant.setup import async_setup_component + +from tests.common import async_fire_time_changed, mock_registry + +MOCK_DEVICE_ID = "28-111111111111" +MOCK_DEVICE_NAME = "My DS18B20" +MOCK_ENTITY_ID = "sensor.my_ds18b20_temperature" + + +async def test_onewiredirect_setup_valid_device(hass): + """Test that sysbus config entry works correctly.""" + entity_registry = mock_registry(hass) + config = { + "sensor": { + "platform": DOMAIN, + "mount_dir": DEFAULT_SYSBUS_MOUNT_DIR, + "port": DEFAULT_OWSERVER_PORT, + "names": { + MOCK_DEVICE_ID: MOCK_DEVICE_NAME, + }, + } + } + + with patch( + "homeassistant.components.onewire.sensor.Pi1Wire" + ) as mock_pi1wire, patch("pi1wire.OneWire") as mock_owsensor: + type(mock_owsensor).mac_address = PropertyMock( + return_value=MOCK_DEVICE_ID.replace("-", "") + ) + mock_owsensor.get_temperature.side_effect = [ + 25.123, + FileNotFoundError, + 25.223, + InvalidCRCException, + 25.323, + NotFoundSensorException, + 25.423, + UnsupportResponseException, + 25.523, + ] + mock_pi1wire.return_value.find_all_sensors.return_value = [mock_owsensor] + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + assert len(entity_registry.entities) == 1 + registry_entry = entity_registry.entities.get(MOCK_ENTITY_ID) + assert registry_entry is not None + assert ( + registry_entry.unique_id == f"/sys/bus/w1/devices/{MOCK_DEVICE_ID}/w1_slave" + ) + assert registry_entry.unit_of_measurement == TEMP_CELSIUS + + # 25.123 + current_time = datetime.now() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "25.1" + + # FileNotFoundError + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "unknown" + + # 25.223 + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "25.2" + + # InvalidCRCException + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "unknown" + + # 25.323 + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "25.3" + + # NotFoundSensorException + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "unknown" + + # 25.423 + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "25.4" + + # UnsupportResponseException + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "unknown" + + # 25.523 + current_time = current_time + timedelta(minutes=2) + async_fire_time_changed(hass, current_time) + await hass.async_block_till_done() + state = hass.states.get(MOCK_ENTITY_ID) + assert state.state == "25.5"