diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index 05bbb9b40ff..5b3e4fd8b9e 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -15,8 +15,6 @@ import re import datetime as dt import functools as ft -import requests - from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, @@ -898,41 +896,6 @@ class Config(object): # Directory that holds the configuration self.config_dir = os.path.join(os.getcwd(), 'config') - def auto_detect(self): - """ Will attempt to detect config of Home Assistant. """ - # Only detect if location or temp unit missing - if None not in (self.latitude, self.longitude, self.temperature_unit): - return - - _LOGGER.info('Auto detecting location and temperature unit') - - try: - info = requests.get( - 'https://freegeoip.net/json/', timeout=5).json() - except requests.RequestException: - return - - if self.latitude is None and self.longitude is None: - self.latitude = info['latitude'] - self.longitude = info['longitude'] - - if self.temperature_unit is None: - # From Wikipedia: - # Fahrenheit is used in the Bahamas, Belize, the Cayman Islands, - # Palau, and the United States and associated territories of - # American Samoa and the U.S. Virgin Islands - if info['country_code'] in ('BS', 'BZ', 'KY', 'PW', - 'US', 'AS', 'VI'): - self.temperature_unit = TEMP_FAHRENHEIT - else: - self.temperature_unit = TEMP_CELCIUS - - if self.location_name is None: - self.location_name = info['city'] - - if self.time_zone is None: - self.time_zone = info['time_zone'] - def path(self, path): """ Returns path to the file within the config dir. """ return os.path.join(self.config_dir, path) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index c5e775bd9f9..a1f9b9e4818 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -72,24 +72,13 @@ def ensure_config_path(config_dir): 'directory {} ').format(config_dir)) sys.exit() - # Try to use yaml configuration first - config_path = os.path.join(config_dir, 'configuration.yaml') - if not os.path.isfile(config_path): - config_path = os.path.join(config_dir, 'home-assistant.conf') + import homeassistant.config as config_util - # Ensure a config file exists to make first time usage easier - if not os.path.isfile(config_path): - config_path = os.path.join(config_dir, 'configuration.yaml') - try: - with open(config_path, 'w') as conf: - conf.write("frontend:\n\n") - conf.write("discovery:\n\n") - conf.write("history:\n\n") - conf.write("logbook:\n\n") - except IOError: - print(('Fatal Error: No configuration file found and unable ' - 'to write a default one to {}').format(config_path)) - sys.exit() + config_path = config_util.ensure_config_exists(config_dir) + + if config_path is None: + print('Error getting configuration path') + sys.exit() return config_path diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e35d2115c65..4f323f34bea 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -10,13 +10,12 @@ start by calling homeassistant.start_home_assistant(bus) """ import os -import configparser -import yaml -import io import logging from collections import defaultdict import homeassistant +import homeassistant.util as util +import homeassistant.config as config_util import homeassistant.loader as loader import homeassistant.components as core_components import homeassistant.components.group as group @@ -148,26 +147,7 @@ def from_config_file(config_path, hass=None): # Set config dir to directory holding config file hass.config.config_dir = os.path.abspath(os.path.dirname(config_path)) - config_dict = {} - # check config file type - if os.path.splitext(config_path)[1] == '.yaml': - # Read yaml - config_dict = yaml.load(io.open(config_path, 'r')) - - # If YAML file was empty - if config_dict is None: - config_dict = {} - - else: - # Read config - config = configparser.ConfigParser() - config.read(config_path) - - for section in config.sections(): - config_dict[section] = {} - - for key, val in config.items(section): - config_dict[section][key] = val + config_dict = config_util.load_config_file(config_path) return from_config_dict(config_dict, hass) @@ -201,12 +181,14 @@ def enable_logging(hass): def process_ha_core_config(hass, config): """ Processes the [homeassistant] section from the config. """ + hac = hass.config + for key, attr in ((CONF_LATITUDE, 'latitude'), (CONF_LONGITUDE, 'longitude'), (CONF_NAME, 'location_name'), (CONF_TIME_ZONE, 'time_zone')): if key in config: - setattr(hass.config, attr, config[key]) + setattr(hac, attr, config[key]) for entity_id, attrs in config.get(CONF_CUSTOMIZE, {}).items(): Entity.overwrite_attribute(entity_id, attrs.keys(), attrs.values()) @@ -215,11 +197,37 @@ def process_ha_core_config(hass, config): unit = config[CONF_TEMPERATURE_UNIT] if unit == 'C': - hass.config.temperature_unit = TEMP_CELCIUS + hac.temperature_unit = TEMP_CELCIUS elif unit == 'F': - hass.config.temperature_unit = TEMP_FAHRENHEIT + hac.temperature_unit = TEMP_FAHRENHEIT - hass.config.auto_detect() + # If we miss some of the needed values, auto detect them + if None not in (hac.latitude, hac.longitude, hac.temperature_unit): + return + + _LOGGER.info('Auto detecting location and temperature unit') + + info = util.detect_location_info() + + if info is None: + _LOGGER.error('Could not detect location information') + return + + if hac.latitude is None and hac.longitude is None: + hac.latitude = info.latitude + hac.longitude = info.longitude + + if hac.temperature_unit is None: + if info.use_fahrenheit: + hac.temperature_unit = TEMP_FAHRENHEIT + else: + hac.temperature_unit = TEMP_CELCIUS + + if hac.location_name is None: + hac.location_name = info.city + + if hac.time_zone is None: + hac.time_zone = info.time_zone def _ensure_loader_prepared(hass): diff --git a/homeassistant/config.py b/homeassistant/config.py new file mode 100644 index 00000000000..b4f70cd1952 --- /dev/null +++ b/homeassistant/config.py @@ -0,0 +1,140 @@ +""" +homeassistant.config +~~~~~~~~~~~~~~~~~~~~ + +Module to help with parsing and generating configuration files. +""" +import logging +import os + +from homeassistant import HomeAssistantError +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_TEMPERATURE_UNIT, CONF_NAME, + CONF_TIME_ZONE) +import homeassistant.util as util + + +_LOGGER = logging.getLogger(__name__) + + +YAML_CONFIG_FILE = 'configuration.yaml' +CONF_CONFIG_FILE = 'home-assistant.conf' +DEFAULT_COMPONENTS = [ + 'discovery', 'frontend', 'conversation', 'history', 'logbook'] + + +def ensure_config_exists(config_dir, detect_location=True): + """ Ensures a config file exists in given config dir. + Creating a default one if needed. + Returns path to the config file. """ + config_path = find_config_file(config_dir) + + if config_path is None: + _LOGGER.info("Unable to find configuration. Creating default one") + config_path = create_default_config(config_dir, detect_location) + + return config_path + + +def create_default_config(config_dir, detect_location=True): + """ Creates a default configuration file in given config dir. + Returns path to new config file if success, None if failed. """ + config_path = os.path.join(config_dir, YAML_CONFIG_FILE) + + # Writing files with YAML does not create the most human readable results + # So we're hard coding a YAML template. + try: + with open(config_path, 'w') as config_file: + location_info = detect_location and util.detect_location_info() + + if location_info: + temp_unit = 'F' if location_info.use_fahrenheit else 'C' + + auto_config = { + CONF_NAME: 'Home', + CONF_LATITUDE: location_info.latitude, + CONF_LONGITUDE: location_info.longitude, + CONF_TEMPERATURE_UNIT: temp_unit, + CONF_TIME_ZONE: location_info.time_zone, + } + + config_file.write("homeassistant:\n") + + for key, value in auto_config.items(): + config_file.write(" {}: {}\n".format(key, value)) + + config_file.write("\n") + + for component in DEFAULT_COMPONENTS: + config_file.write("{}:\n\n".format(component)) + + return config_path + + except IOError: + _LOGGER.exception( + 'Unable to write default configuration file %s', config_path) + + return None + + +def find_config_file(config_dir): + """ Looks in given directory for supported config files. """ + for filename in (YAML_CONFIG_FILE, CONF_CONFIG_FILE): + config_path = os.path.join(config_dir, filename) + + if os.path.isfile(config_path): + return config_path + + return None + + +def load_config_file(config_path): + """ Loads given config file. """ + config_ext = os.path.splitext(config_path)[1] + + if config_ext == '.yaml': + return load_yaml_config_file(config_path) + + elif config_ext == '.conf': + return load_conf_config_file(config_path) + + +def load_yaml_config_file(config_path): + """ Parse a YAML configuration file. """ + import yaml + + try: + with open(config_path) as conf_file: + # If configuration file is empty YAML returns None + # We convert that to an empty dict + conf_dict = yaml.load(conf_file) or {} + + except yaml.YAMLError: + _LOGGER.exception('Error reading YAML configuration file') + raise HomeAssistantError() + + if not isinstance(conf_dict, dict): + _LOGGER.error( + 'The configuration file %s does not contain a dictionary', + os.path.basename(config_path)) + raise HomeAssistantError() + + return conf_dict + + +def load_conf_config_file(config_path): + """ Parse the old style conf configuration. """ + import configparser + + config_dict = {} + + config = configparser.ConfigParser() + config.read(config_path) + + for section in config.sections(): + config_dict[section] = {} + + for key, val in config.items(section): + config_dict[section][key] = val + + return config_dict diff --git a/homeassistant/util.py b/homeassistant/util.py index ae5fcea609d..e50ac3f30cf 100644 --- a/homeassistant/util.py +++ b/homeassistant/util.py @@ -16,6 +16,8 @@ import random import string from functools import wraps +import requests + RE_SANITIZE_FILENAME = re.compile(r'(~|\.\.|/|\\)') RE_SANITIZE_PATH = re.compile(r'(~|\.(\.)+)') RE_SLUGIFY = re.compile(r'[^A-Za-z0-9_]+') @@ -174,6 +176,32 @@ def get_random_string(length=10): return ''.join(generator.choice(source_chars) for _ in range(length)) +LocationInfo = collections.namedtuple( + "LocationInfo", + ['ip', 'country_code', 'country_name', 'region_code', 'region_name', + 'city', 'zip_code', 'time_zone', 'latitude', 'longitude', + 'use_fahrenheit']) + + +def detect_location_info(): + """ Detect location information. """ + try: + raw_info = requests.get( + 'https://freegeoip.net/json/', timeout=5).json() + except requests.RequestException: + return + + data = {key: raw_info.get(key) for key in LocationInfo._fields} + + # From Wikipedia: Fahrenheit is used in the Bahamas, Belize, + # the Cayman Islands, Palau, and the United States and associated + # territories of American Samoa and the U.S. Virgin Islands + data['use_fahrenheit'] = data['country_code'] in ( + 'BS', 'BZ', 'KY', 'PW', 'US', 'AS', 'VI') + + return LocationInfo(**data) + + class OrderedEnum(enum.Enum): """ Taken from Python 3.4.0 docs. """ # pylint: disable=no-init, too-few-public-methods diff --git a/tests/helpers.py b/tests/helpers.py index d98c549346d..33b4468cfac 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,10 +11,15 @@ from homeassistant.helpers.entity import ToggleEntity from homeassistant.const import STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME +def get_test_config_dir(): + """ Returns a path to a test config dir. """ + return os.path.join(os.path.dirname(__file__), "config") + + def get_test_home_assistant(): """ Returns a Home Assistant object pointing at test config dir. """ hass = ha.HomeAssistant() - hass.config.config_dir = os.path.join(os.path.dirname(__file__), "config") + hass.config.config_dir = get_test_config_dir() return hass diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000000..133f7d51f71 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,180 @@ +""" +tests.test_config +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests config utils. +""" +# pylint: disable=too-many-public-methods,protected-access +import unittest +import unittest.mock as mock +import os + +from homeassistant import DOMAIN, HomeAssistantError +import homeassistant.util as util +import homeassistant.config as config_util +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_TEMPERATURE_UNIT, CONF_NAME, + CONF_TIME_ZONE) + +from helpers import get_test_config_dir + +CONFIG_DIR = get_test_config_dir() +YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) +CONF_PATH = os.path.join(CONFIG_DIR, config_util.CONF_CONFIG_FILE) + + +def create_file(path): + """ Creates an empty file. """ + with open(path, 'w'): + pass + + +def mock_detect_location_info(): + """ Mock implementation of util.detect_location_info. """ + return util.LocationInfo( + ip='1.1.1.1', + country_code='US', + country_name='United States', + region_code='CA', + region_name='California', + city='San Diego', + zip_code='92122', + time_zone='America/Los_Angeles', + latitude='2.0', + longitude='1.0', + use_fahrenheit=True, + ) + + +class TestConfig(unittest.TestCase): + """ Test the config utils. """ + + def tearDown(self): # pylint: disable=invalid-name + """ Clean up. """ + for path in (YAML_PATH, CONF_PATH): + if os.path.isfile(path): + os.remove(path) + + def test_create_default_config(self): + """ Test creationg of default config. """ + + config_util.create_default_config(CONFIG_DIR, False) + + self.assertTrue(os.path.isfile(YAML_PATH)) + + def test_find_config_file_yaml(self): + """ Test if it finds a YAML config file. """ + + create_file(YAML_PATH) + + self.assertEqual(YAML_PATH, config_util.find_config_file(CONFIG_DIR)) + + def test_find_config_file_conf(self): + """ Test if it finds the old CONF config file. """ + + create_file(CONF_PATH) + + self.assertEqual(CONF_PATH, config_util.find_config_file(CONFIG_DIR)) + + def test_find_config_file_prefers_yaml_over_conf(self): + """ Test if find config prefers YAML over CONF if both exist. """ + + create_file(YAML_PATH) + create_file(CONF_PATH) + + self.assertEqual(YAML_PATH, config_util.find_config_file(CONFIG_DIR)) + + def test_ensure_config_exists_creates_config(self): + """ Test that calling ensure_config_exists creates a new config file if + none exists. """ + + config_util.ensure_config_exists(CONFIG_DIR, False) + + self.assertTrue(os.path.isfile(YAML_PATH)) + + def test_ensure_config_exists_uses_existing_config(self): + """ Test that calling ensure_config_exists uses existing config. """ + + create_file(YAML_PATH) + config_util.ensure_config_exists(CONFIG_DIR, False) + + with open(YAML_PATH) as f: + content = f.read() + + # File created with create_file are empty + self.assertEqual('', content) + + def test_load_yaml_config_converts_empty_files_to_dict(self): + """ Test that loading an empty file returns an empty dict. """ + create_file(YAML_PATH) + + self.assertIsInstance( + config_util.load_yaml_config_file(YAML_PATH), dict) + + def test_load_yaml_config_raises_error_if_not_dict(self): + """ Test error raised when YAML file is not a dict. """ + with open(YAML_PATH, 'w') as f: + f.write('5') + + with self.assertRaises(HomeAssistantError): + config_util.load_yaml_config_file(YAML_PATH) + + def test_load_yaml_config_raises_error_if_malformed_yaml(self): + """ Test error raised if invalid YAML. """ + with open(YAML_PATH, 'w') as f: + f.write(':') + + with self.assertRaises(HomeAssistantError): + config_util.load_yaml_config_file(YAML_PATH) + + def test_load_config_loads_yaml_config(self): + """ Test correct YAML config loading. """ + with open(YAML_PATH, 'w') as f: + f.write('hello: world') + + self.assertEqual({'hello': 'world'}, + config_util.load_config_file(YAML_PATH)) + + def test_load_config_loads_conf_config(self): + """ Test correct YAML config loading. """ + create_file(CONF_PATH) + + self.assertEqual({}, config_util.load_config_file(CONF_PATH)) + + def test_conf_config_file(self): + """ Test correct CONF config loading. """ + with open(CONF_PATH, 'w') as f: + f.write('[ha]\ntime_zone=America/Los_Angeles') + + self.assertEqual({'ha': {'time_zone': 'America/Los_Angeles'}}, + config_util.load_conf_config_file(CONF_PATH)) + + def test_create_default_config_detect_location(self): + """ Test that detect location sets the correct config keys. """ + with mock.patch('homeassistant.util.detect_location_info', + mock_detect_location_info): + config_util.ensure_config_exists(CONFIG_DIR) + + config = config_util.load_config_file(YAML_PATH) + + self.assertIn(DOMAIN, config) + + ha_conf = config[DOMAIN] + + expected_values = { + CONF_LATITUDE: 2.0, + CONF_LONGITUDE: 1.0, + CONF_TEMPERATURE_UNIT: 'F', + CONF_NAME: 'Home', + CONF_TIME_ZONE: 'America/Los_Angeles' + } + + self.assertEqual(expected_values, ha_conf) + + def test_create_default_config_returns_none_if_write_error(self): + """ + Test that writing default config to non existing folder returns None. + """ + self.assertIsNone( + config_util.create_default_config( + os.path.join(CONFIG_DIR, 'non_existing_dir/'), False))