diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 9efab69ab26..dd270a0bb75 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -12,22 +12,25 @@ import aiohttp import async_timeout import voluptuous as vol -from homeassistant.const import ATTR_FRIENDLY_NAME, __version__ as current_version +from homeassistant.const import __version__ as current_version from homeassistant.helpers import event from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import async_dispatcher_send import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) ATTR_RELEASE_NOTES = "release_notes" +ATTR_NEWEST_VERSION = "newest_version" CONF_REPORTING = "reporting" CONF_COMPONENT_REPORTING = "include_used_components" DOMAIN = "updater" -ENTITY_ID = "updater.updater" +DISPATCHER_REMOTE_UPDATE = "updater_remote_update" UPDATER_URL = "https://updater.home-assistant.io/" UPDATER_UUID_FILE = ".uuid" @@ -47,6 +50,16 @@ RESPONSE_SCHEMA = vol.Schema( ) +class Updater: + """Updater class for data exchange.""" + + def __init__(self, update_available: bool, newest_version: str, release_notes: str): + """Initialize attributes.""" + self.update_available = update_available + self.release_notes = release_notes + self.newest_version = newest_version + + def _create_uuid(hass, filename=UPDATER_UUID_FILE): """Create UUID and save it in a file.""" with open(hass.config.path(filename), "w") as fptr: @@ -73,6 +86,10 @@ async def async_setup(hass, config): # This component only makes sense in release versions _LOGGER.info("Running on 'dev', only analytics will be submitted") + hass.async_create_task( + discovery.async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) + ) + config = config.get(DOMAIN, {}) if config.get(CONF_REPORTING): huuid = await hass.async_add_job(_load_uuid, hass) @@ -88,7 +105,7 @@ async def async_setup(hass, config): if result is None: return - newest, releasenotes = result + newest, release_notes = result # Skip on dev if newest is None or "dev" in current_version: @@ -99,18 +116,17 @@ async def async_setup(hass, config): newest = hass.components.hassio.get_homeassistant_version() # Validate version + update_available = False if StrictVersion(newest) > StrictVersion(current_version): - _LOGGER.info("The latest available version is %s", newest) - hass.states.async_set( - ENTITY_ID, - newest, - { - ATTR_FRIENDLY_NAME: "Update Available", - ATTR_RELEASE_NOTES: releasenotes, - }, - ) + _LOGGER.info("The latest available version of Home Assistant is %s", newest) + update_available = True elif StrictVersion(newest) == StrictVersion(current_version): _LOGGER.info("You are on the latest version (%s) of Home Assistant", newest) + elif StrictVersion(newest) < StrictVersion(current_version): + _LOGGER.debug("Local version is newer than the latest version (%s)", newest) + + updater = Updater(update_available, newest, release_notes) + async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, updater) # Update daily, start 1 hour after startup _dt = dt_util.utcnow() + timedelta(hours=1) @@ -151,7 +167,7 @@ async def get_newest_version(hass, huuid, include_components): info_object, ) except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Could not contact Home Assistant Update to check " "for updates") + _LOGGER.error("Could not contact Home Assistant Update to check for updates") return None try: diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py new file mode 100644 index 00000000000..cae3ae32e3c --- /dev/null +++ b/homeassistant/components/updater/binary_sensor.py @@ -0,0 +1,81 @@ +"""Support for Home Assistant Updater binary sensors.""" + +from homeassistant.core import callback +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ATTR_NEWEST_VERSION, ATTR_RELEASE_NOTES, DISPATCHER_REMOTE_UPDATE, Updater + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the updater binary sensors.""" + async_add_entities([UpdaterBinary()]) + + +class UpdaterBinary(BinarySensorDevice): + """Representation of an updater binary sensor.""" + + def __init__(self): + """Initialize the binary sensor.""" + self._update_available = None + self._release_notes = None + self._newest_version = None + self._unsub_dispatcher = None + + @property + def name(self) -> str: + """Return the name of the binary sensor, if any.""" + return "Updater" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "updater" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._update_available + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._update_available is not None + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state.""" + return False + + @property + def device_state_attributes(self) -> dict: + """Return the optional state attributes.""" + data = super().device_state_attributes + if data is None: + data = {} + if self._release_notes: + data[ATTR_RELEASE_NOTES] = self._release_notes + if self._newest_version: + data[ATTR_NEWEST_VERSION] = self._newest_version + return data + + async def async_added_to_hass(self): + """Register update dispatcher.""" + + @callback + def async_state_update(updater: Updater): + """Update callback.""" + self._newest_version = updater.newest_version + self._release_notes = updater.release_notes + self._update_available = updater.update_available + self.async_schedule_update_ha_state() + + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + ) + + async def async_will_remove_from_hass(self): + """Register update dispatcher.""" + if self._unsub_dispatcher is not None: + self._unsub_dispatcher() + self._unsub_dispatcher = None diff --git a/tests/components/updater/test_init.py b/tests/components/updater/test_init.py index 0269f269027..014fb7b6f45 100644 --- a/tests/components/updater/test_init.py +++ b/tests/components/updater/test_init.py @@ -21,6 +21,7 @@ MOCK_DEV_VERSION = "10.0.dev0" MOCK_HUUID = "abcdefg" MOCK_RESPONSE = {"version": "0.15", "release-notes": "https://home-assistant.io"} MOCK_CONFIG = {updater.DOMAIN: {"reporting": True}} +RELEASE_NOTES = "test release notes" @pytest.fixture(autouse=True) @@ -44,56 +45,138 @@ def mock_get_uuid(): yield mock +@pytest.fixture +def mock_utcnow(): + """Fixture to mock utcnow.""" + with patch("homeassistant.components.updater.dt_util.utcnow") as mock: + yield mock + + @asyncio.coroutine -def test_new_version_shows_entity_after_hour( - hass, mock_get_uuid, mock_get_newest_version -): - """Test if new entity is created if new version is available.""" +def test_new_version_shows_entity_startup(hass, mock_get_uuid, mock_get_newest_version): + """Test if binary sensor is unavailable at first.""" mock_get_uuid.return_value = MOCK_HUUID - mock_get_newest_version.return_value = mock_coro((NEW_VERSION, "")) + mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES)) res = yield from async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}}) assert res, "Updater failed to set up" - with patch("homeassistant.components.updater.current_version", MOCK_VERSION): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) - yield from hass.async_block_till_done() - - assert hass.states.is_state(updater.ENTITY_ID, NEW_VERSION) + yield from hass.async_block_till_done() + assert hass.states.is_state("binary_sensor.updater", "unavailable") + assert "newest_version" not in hass.states.get("binary_sensor.updater").attributes + assert "release_notes" not in hass.states.get("binary_sensor.updater").attributes @asyncio.coroutine -def test_same_version_not_show_entity(hass, mock_get_uuid, mock_get_newest_version): - """Test if new entity is created if new version is available.""" +def test_rename_entity(hass, mock_get_uuid, mock_get_newest_version): + """Test if renaming the binary sensor works correctly.""" + mock_get_uuid.return_value = MOCK_HUUID + mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES)) + + now = dt_util.utcnow() + later = now + timedelta(hours=1) + mock_utcnow.return_value = now + + res = yield from async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}}) + assert res, "Updater failed to set up" + + yield from hass.async_block_till_done() + assert hass.states.is_state("binary_sensor.updater", "unavailable") + assert hass.states.get("binary_sensor.new_entity_id") is None + + entity_registry = yield from hass.helpers.entity_registry.async_get_registry() + entity_registry.async_update_entity( + "binary_sensor.updater", new_entity_id="binary_sensor.new_entity_id" + ) + + yield from hass.async_block_till_done() + assert hass.states.is_state("binary_sensor.new_entity_id", "unavailable") + assert hass.states.get("binary_sensor.updater") is None + + with patch("homeassistant.components.updater.current_version", MOCK_VERSION): + async_fire_time_changed(hass, later) + yield from hass.async_block_till_done() + + assert hass.states.is_state("binary_sensor.new_entity_id", "on") + assert hass.states.get("binary_sensor.updater") is None + + +@asyncio.coroutine +def test_new_version_shows_entity_true(hass, mock_get_uuid, mock_get_newest_version): + """Test if sensor is true if new version is available.""" + mock_get_uuid.return_value = MOCK_HUUID + mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES)) + + now = dt_util.utcnow() + later = now + timedelta(hours=1) + mock_utcnow.return_value = now + + res = yield from async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}}) + assert res, "Updater failed to set up" + + yield from hass.async_block_till_done() + with patch("homeassistant.components.updater.current_version", MOCK_VERSION): + async_fire_time_changed(hass, later) + yield from hass.async_block_till_done() + + assert hass.states.is_state("binary_sensor.updater", "on") + assert ( + hass.states.get("binary_sensor.updater").attributes["newest_version"] + == NEW_VERSION + ) + assert ( + hass.states.get("binary_sensor.updater").attributes["release_notes"] + == RELEASE_NOTES + ) + + +@asyncio.coroutine +def test_same_version_shows_entity_false(hass, mock_get_uuid, mock_get_newest_version): + """Test if sensor is false if no new version is available.""" mock_get_uuid.return_value = MOCK_HUUID mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, "")) + now = dt_util.utcnow() + later = now + timedelta(hours=1) + mock_utcnow.return_value = now + res = yield from async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}}) assert res, "Updater failed to set up" + yield from hass.async_block_till_done() with patch("homeassistant.components.updater.current_version", MOCK_VERSION): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) + async_fire_time_changed(hass, later) yield from hass.async_block_till_done() - assert hass.states.get(updater.ENTITY_ID) is None + assert hass.states.is_state("binary_sensor.updater", "off") + assert ( + hass.states.get("binary_sensor.updater").attributes["newest_version"] + == MOCK_VERSION + ) + assert "release_notes" not in hass.states.get("binary_sensor.updater").attributes @asyncio.coroutine def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version): - """Test if new entity is created if new version is available.""" + """Test we do not gather analytics when disable reporting is active.""" mock_get_uuid.return_value = MOCK_HUUID mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, "")) + now = dt_util.utcnow() + later = now + timedelta(hours=1) + mock_utcnow.return_value = now + res = yield from async_setup_component( hass, updater.DOMAIN, {updater.DOMAIN: {"reporting": False}} ) assert res, "Updater failed to set up" + yield from hass.async_block_till_done() with patch("homeassistant.components.updater.current_version", MOCK_VERSION): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) + async_fire_time_changed(hass, later) yield from hass.async_block_till_done() - assert hass.states.get(updater.ENTITY_ID) is None + assert hass.states.is_state("binary_sensor.updater", "off") res = yield from updater.get_newest_version(hass, MOCK_HUUID, MOCK_CONFIG) call = mock_get_newest_version.mock_calls[0][1] assert call[0] is hass @@ -114,7 +197,7 @@ def test_get_newest_version_no_analytics_when_no_huuid(hass, aioclient_mock): @asyncio.coroutine def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock): - """Test we do not gather analytics when no huuid is passed in.""" + """Test we gather analytics when huuid is passed in.""" aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE) with patch( @@ -127,7 +210,7 @@ def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock): @asyncio.coroutine def test_error_fetching_new_version_timeout(hass): - """Test we do not gather analytics when no huuid is passed in.""" + """Test we handle timeout error while fetching new version.""" with patch( "homeassistant.helpers.system_info.async_get_system_info", Mock(return_value=mock_coro({"fake": "bla"})), @@ -138,7 +221,7 @@ def test_error_fetching_new_version_timeout(hass): @asyncio.coroutine def test_error_fetching_new_version_bad_json(hass, aioclient_mock): - """Test we do not gather analytics when no huuid is passed in.""" + """Test we handle json error while fetching new version.""" aioclient_mock.post(updater.UPDATER_URL, text="not json") with patch( @@ -151,7 +234,7 @@ def test_error_fetching_new_version_bad_json(hass, aioclient_mock): @asyncio.coroutine def test_error_fetching_new_version_invalid_response(hass, aioclient_mock): - """Test we do not gather analytics when no huuid is passed in.""" + """Test we handle response error while fetching new version.""" aioclient_mock.post( updater.UPDATER_URL, json={ @@ -172,17 +255,29 @@ def test_error_fetching_new_version_invalid_response(hass, aioclient_mock): def test_new_version_shows_entity_after_hour_hassio( hass, mock_get_uuid, mock_get_newest_version ): - """Test if new entity is created if new version is available / hass.io.""" + """Test if binary sensor gets updated if new version is available / hass.io.""" mock_get_uuid.return_value = MOCK_HUUID - mock_get_newest_version.return_value = mock_coro((NEW_VERSION, "")) + mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES)) mock_component(hass, "hassio") hass.data["hassio_hass_version"] = "999.0" + now = dt_util.utcnow() + later = now + timedelta(hours=1) + mock_utcnow.return_value = now + res = yield from async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}}) assert res, "Updater failed to set up" + yield from hass.async_block_till_done() with patch("homeassistant.components.updater.current_version", MOCK_VERSION): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) + async_fire_time_changed(hass, later) yield from hass.async_block_till_done() - assert hass.states.is_state(updater.ENTITY_ID, "999.0") + assert hass.states.is_state("binary_sensor.updater", "on") + assert ( + hass.states.get("binary_sensor.updater").attributes["newest_version"] == "999.0" + ) + assert ( + hass.states.get("binary_sensor.updater").attributes["release_notes"] + == RELEASE_NOTES + )