From c70722dbaef64cb00dbf6793d84b5530856a35cb Mon Sep 17 00:00:00 2001
From: Johann Kellerman <kellerza@gmail.com>
Date: Thu, 20 Oct 2016 21:30:44 +0200
Subject: [PATCH] Updater component with basic system reporting (#3781)

---
 homeassistant/components/updater.py | 106 +++++++++++++++++++++-------
 requirements_all.txt                |   3 +
 tests/components/test_updater.py    |  85 +++++++++++++++-------
 3 files changed, 142 insertions(+), 52 deletions(-)

diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py
index ec91149a87d..76dd9d8c09e 100644
--- a/homeassistant/components/updater.py
+++ b/homeassistant/components/updater.py
@@ -4,62 +4,118 @@ Support to check for available updates.
 For more details about this component, please refer to the documentation at
 https://home-assistant.io/components/updater/
 """
+from datetime import datetime, timedelta
 import logging
+import json
+import platform
+import uuid
+# pylint: disable=no-name-in-module,import-error
+from distutils.version import StrictVersion
 
 import requests
 import voluptuous as vol
 
 from homeassistant.const import __version__ as CURRENT_VERSION
 from homeassistant.const import ATTR_FRIENDLY_NAME
+import homeassistant.util.dt as dt_util
 from homeassistant.helpers import event
+import homeassistant.helpers.config_validation as cv
 
 _LOGGER = logging.getLogger(__name__)
-
+UPDATER_URL = 'https://updater.home-assistant.io/'
 DOMAIN = 'updater'
-
 ENTITY_ID = 'updater.updater'
+ATTR_RELEASE_NOTES = 'release_notes'
+UPDATER_UUID_FILE = '.uuid'
+CONF_OPT_OUT = 'opt_out'
+
+REQUIREMENTS = ['distro==1.0.0']
+
+CONFIG_SCHEMA = vol.Schema({DOMAIN: {
+    vol.Optional(CONF_OPT_OUT, default=False): cv.boolean
+}}, extra=vol.ALLOW_EXTRA)
 
-PYPI_URL = 'https://pypi.python.org/pypi/homeassistant/json'
 
-CONFIG_SCHEMA = vol.Schema({
-    DOMAIN: vol.Schema({}),
-}, extra=vol.ALLOW_EXTRA)
+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:
+        _uuid = uuid.uuid4().hex
+        fptr.write(json.dumps({"uuid": _uuid}))
+        return _uuid
+
+
+def _load_uuid(hass, filename=UPDATER_UUID_FILE):
+    """Load UUID from a file, or return None."""
+    try:
+        with open(hass.config.path(filename)) as fptr:
+            jsonf = json.loads(fptr.read())
+            return uuid.UUID(jsonf['uuid'], version=4).hex
+    except (ValueError, AttributeError):
+        return None
+    except FileNotFoundError:
+        return _create_uuid(hass, filename)
 
 
 def setup(hass, config):
     """Setup the updater component."""
     if 'dev' in CURRENT_VERSION:
-        _LOGGER.warning("Updater not supported in development version")
+        # This component only makes sense in release versions
+        _LOGGER.warning('Updater not supported in development version')
         return False
 
-    def check_newest_version(_=None):
-        """Check if a new version is available and report if one is."""
-        newest = get_newest_version()
-
-        if newest != CURRENT_VERSION and newest is not None:
-            hass.states.set(
-                ENTITY_ID, newest, {ATTR_FRIENDLY_NAME: 'Update available'})
+    huuid = None if config.get(CONF_OPT_OUT) else _load_uuid(hass)
 
+    # Update daily, start 1 hour after startup
+    _dt = datetime.now() + timedelta(hours=1)
     event.track_time_change(
-        hass, check_newest_version, hour=[0, 12], minute=0, second=0)
-
-    check_newest_version()
+        hass, lambda _: check_newest_version(hass, huuid),
+        hour=_dt.hour, minute=_dt.minute, second=_dt.second)
 
     return True
 
 
-def get_newest_version():
-    """Get the newest Home Assistant version from PyPI."""
-    try:
-        req = requests.get(PYPI_URL)
+def check_newest_version(hass, huuid):
+    """Check if a new version is available and report if one is."""
+    newest, releasenotes = get_newest_version(huuid)
 
-        return req.json()['info']['version']
+    if newest is not None:
+        if StrictVersion(newest) > StrictVersion(CURRENT_VERSION):
+            hass.states.set(
+                ENTITY_ID, newest, {ATTR_FRIENDLY_NAME: 'Update Available',
+                                    ATTR_RELEASE_NOTES: releasenotes}
+            )
+
+
+def get_newest_version(huuid):
+    """Get the newest Home Assistant version."""
+    info_object = {'uuid': huuid, 'version': CURRENT_VERSION,
+                   'timezone': dt_util.DEFAULT_TIME_ZONE.zone,
+                   'os_name': platform.system(), "arch": platform.machine(),
+                   'python_version': platform.python_version()}
+
+    if platform.system() == 'Windows':
+        info_object['os_version'] = platform.win32_ver()[0]
+    elif platform.system() == 'Darwin':
+        info_object['os_version'] = platform.mac_ver()[0]
+    elif platform.system() == 'Linux':
+        import distro
+        linux_dist = distro.linux_distribution(full_distribution_name=False)
+        info_object['distribution'] = linux_dist[0]
+        info_object['os_version'] = linux_dist[1]
+
+    if not huuid:
+        info_object = {}
+
+    try:
+        req = requests.post(UPDATER_URL, json=info_object)
+        res = req.json()
+        return (res['version'], res['release-notes'])
     except requests.RequestException:
-        _LOGGER.exception("Could not contact PyPI to check for updates")
+        _LOGGER.exception('Could not contact HASS Update to check for updates')
         return None
     except ValueError:
-        _LOGGER.exception("Received invalid response from PyPI")
+        _LOGGER.exception('Received invalid response from HASS Update')
         return None
     except KeyError:
-        _LOGGER.exception("Response from PyPI did not include version")
+        _LOGGER.exception('Response from HASS Update did not include version')
         return None
diff --git a/requirements_all.txt b/requirements_all.txt
index 03f35a03a37..4d05b474fe7 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -79,6 +79,9 @@ concord232==0.14
 # homeassistant.components.media_player.directv
 directpy==0.1
 
+# homeassistant.components.updater
+distro==1.0.0
+
 # homeassistant.components.notify.xmpp
 dnspython3==1.15.0
 
diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py
index ec958a0d264..74fc7fc8cd4 100644
--- a/tests/components/test_updater.py
+++ b/tests/components/test_updater.py
@@ -1,13 +1,16 @@
 """The tests for the Updater component."""
+from datetime import datetime, timedelta
 import unittest
 from unittest.mock import patch
+import os
 
 import requests
 
 from homeassistant.bootstrap import setup_component
 from homeassistant.components import updater
-import homeassistant.util.dt as dt_util
-from tests.common import fire_time_changed, get_test_home_assistant
+
+from tests.common import (
+    assert_setup_component, fire_time_changed, get_test_home_assistant)
 
 NEW_VERSION = '10000.0'
 
@@ -18,65 +21,93 @@ MOCK_CURRENT_VERSION = '10.0'
 class TestUpdater(unittest.TestCase):
     """Test the Updater component."""
 
-    def setUp(self):  # pylint: disable=invalid-name
+    hass = None
+
+    def setup_method(self, _):
         """Setup things to be run when tests are started."""
         self.hass = get_test_home_assistant()
 
-    def tearDown(self):  # pylint: disable=invalid-name
+    def teardown_method(self, _):
         """Stop everything that was started."""
         self.hass.stop()
 
     @patch('homeassistant.components.updater.get_newest_version')
-    def test_new_version_shows_entity_on_start(self, mock_get_newest_version):
+    def test_new_version_shows_entity_on_start(  # pylint: disable=invalid-name
+            self, mock_get_newest_version):
         """Test if new entity is created if new version is available."""
-        mock_get_newest_version.return_value = NEW_VERSION
+        mock_get_newest_version.return_value = (NEW_VERSION, '')
         updater.CURRENT_VERSION = MOCK_CURRENT_VERSION
 
-        self.assertTrue(setup_component(self.hass, updater.DOMAIN, {
-            'updater': {}
-        }))
+        with assert_setup_component(1) as config:
+            setup_component(self.hass, updater.DOMAIN, {updater.DOMAIN: {}})
+            _dt = datetime.now() + timedelta(hours=1)
+            assert config['updater'] == {'opt_out': False}
+
+        for secs in [-1, 0, 1]:
+            fire_time_changed(self.hass, _dt + timedelta(seconds=secs))
+            self.hass.block_till_done()
 
         self.assertTrue(self.hass.states.is_state(
             updater.ENTITY_ID, NEW_VERSION))
 
     @patch('homeassistant.components.updater.get_newest_version')
-    def test_no_entity_on_same_version(self, mock_get_newest_version):
+    def test_no_entity_on_same_version(  # pylint: disable=invalid-name
+            self, mock_get_newest_version):
         """Test if no entity is created if same version."""
-        mock_get_newest_version.return_value = MOCK_CURRENT_VERSION
+        mock_get_newest_version.return_value = (MOCK_CURRENT_VERSION, '')
         updater.CURRENT_VERSION = MOCK_CURRENT_VERSION
 
-        self.assertTrue(setup_component(self.hass, updater.DOMAIN, {
-            'updater': {}
-        }))
+        with assert_setup_component(1) as config:
+            assert setup_component(
+                self.hass, updater.DOMAIN, {updater.DOMAIN: {}})
+            _dt = datetime.now() + timedelta(hours=1)
+            assert config['updater'] == {'opt_out': False}
 
         self.assertIsNone(self.hass.states.get(updater.ENTITY_ID))
 
-        mock_get_newest_version.return_value = NEW_VERSION
-
-        fire_time_changed(
-            self.hass, dt_util.utcnow().replace(hour=0, minute=0, second=0))
+        mock_get_newest_version.return_value = (NEW_VERSION, '')
 
-        self.hass.block_till_done()
+        for secs in [-1, 0, 1]:
+            fire_time_changed(self.hass, _dt + timedelta(seconds=secs))
+            self.hass.block_till_done()
 
         self.assertTrue(self.hass.states.is_state(
             updater.ENTITY_ID, NEW_VERSION))
 
-    @patch('homeassistant.components.updater.requests.get')
-    def test_errors_while_fetching_new_version(self, mock_get):
+    @patch('homeassistant.components.updater.requests.post')
+    def test_errors_while_fetching_new_version(  # pylint: disable=invalid-name
+            self, mock_get):
         """Test for errors while fetching the new version."""
         mock_get.side_effect = requests.RequestException
-        self.assertIsNone(updater.get_newest_version())
+        uuid = '0000'
+        self.assertIsNone(updater.get_newest_version(uuid))
 
         mock_get.side_effect = ValueError
-        self.assertIsNone(updater.get_newest_version())
+        self.assertIsNone(updater.get_newest_version(uuid))
 
         mock_get.side_effect = KeyError
-        self.assertIsNone(updater.get_newest_version())
+        self.assertIsNone(updater.get_newest_version(uuid))
 
     def test_updater_disabled_on_dev(self):
         """Test if the updater component is disabled on dev."""
         updater.CURRENT_VERSION = MOCK_CURRENT_VERSION + 'dev'
 
-        self.assertFalse(setup_component(self.hass, updater.DOMAIN, {
-            'updater': {}
-        }))
+        with assert_setup_component(1) as config:
+            assert not setup_component(
+                self.hass, updater.DOMAIN, {updater.DOMAIN: {}})
+            assert config['updater'] == {'opt_out': False}
+
+    def test_uuid_function(self):
+        """Test if the uuid function works."""
+        path = self.hass.config.path(updater.UPDATER_UUID_FILE)
+        try:
+            # pylint: disable=protected-access
+            uuid = updater._load_uuid(self.hass)
+            assert os.path.isfile(path)
+            uuid2 = updater._load_uuid(self.hass)
+            assert uuid == uuid2
+            os.remove(path)
+            uuid2 = updater._load_uuid(self.hass)
+            assert uuid != uuid2
+        finally:
+            os.remove(path)
-- 
GitLab