diff --git a/.coveragerc b/.coveragerc index fa2ec6e9f27..38c88c4748c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -655,6 +655,7 @@ omit = homeassistant/components/sensor/nederlandse_spoorwegen.py homeassistant/components/sensor/netdata.py homeassistant/components/sensor/neurio_energy.py + homeassistant/components/sensor/nsw_fuel_station.py homeassistant/components/sensor/nut.py homeassistant/components/sensor/nzbget.py homeassistant/components/sensor/ohmconnect.py diff --git a/CODEOWNERS b/CODEOWNERS index 0da8353e5aa..556791b879c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -70,6 +70,7 @@ homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel +homeassistant/components/sensor/nsw_fuel_station.py @nickw444 homeassistant/components/sensor/pollen.py @bachya homeassistant/components/sensor/qnap.py @colinodell homeassistant/components/sensor/sma.py @kellerza diff --git a/homeassistant/components/sensor/nsw_fuel_station.py b/homeassistant/components/sensor/nsw_fuel_station.py new file mode 100644 index 00000000000..2440dac3204 --- /dev/null +++ b/homeassistant/components/sensor/nsw_fuel_station.py @@ -0,0 +1,174 @@ +""" +Sensor platform to display the current fuel prices at a NSW fuel station. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.nsw_fuel_station/ +""" +import datetime +import logging +from typing import Optional + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['nsw-fuel-api-client==1.0.10'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION_ID = 'station_id' +ATTR_STATION_NAME = 'station_name' + +CONF_STATION_ID = 'station_id' +CONF_FUEL_TYPES = 'fuel_types' +CONF_ALLOWED_FUEL_TYPES = ["E10", "U91", "E85", "P95", "P98", "DL", + "PDL", "B20", "LPG", "CNG", "EV"] +CONF_DEFAULT_FUEL_TYPES = ["E10", "U91"] +CONF_ATTRIBUTION = "Data provided by NSW Government FuelCheck" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STATION_ID): cv.positive_int, + vol.Optional(CONF_FUEL_TYPES, default=CONF_DEFAULT_FUEL_TYPES): + vol.All(cv.ensure_list, [vol.In(CONF_ALLOWED_FUEL_TYPES)]), +}) + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(hours=1) + +NOTIFICATION_ID = 'nsw_fuel_station_notification' +NOTIFICATION_TITLE = 'NSW Fuel Station Sensor Setup' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the NSW Fuel Station sensor.""" + from nsw_fuel import FuelCheckClient + + station_id = config[CONF_STATION_ID] + fuel_types = config[CONF_FUEL_TYPES] + + client = FuelCheckClient() + station_data = StationPriceData(client, station_id) + station_data.update() + + if station_data.error is not None: + message = ( + 'Error: {}. Check the logs for additional information.' + ).format(station_data.error) + + hass.components.persistent_notification.create( + message, + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return + + available_fuel_types = station_data.get_available_fuel_types() + + add_devices([ + StationPriceSensor(station_data, fuel_type) + for fuel_type in fuel_types + if fuel_type in available_fuel_types + ]) + + +class StationPriceData(object): + """An object to store and fetch the latest data for a given station.""" + + def __init__(self, client, station_id: int) -> None: + """Initialize the sensor.""" + self.station_id = station_id + self._client = client + self._data = None + self._reference_data = None + self.error = None + self._station_name = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update the internal data using the API client.""" + from nsw_fuel import FuelCheckError + + if self._reference_data is None: + try: + self._reference_data = self._client.get_reference_data() + except FuelCheckError as exc: + self.error = str(exc) + _LOGGER.error( + 'Failed to fetch NSW Fuel station reference data. %s', exc) + return + + try: + self._data = self._client.get_fuel_prices_for_station( + self.station_id) + except FuelCheckError as exc: + self.error = str(exc) + _LOGGER.error( + 'Failed to fetch NSW Fuel station price data. %s', exc) + + def for_fuel_type(self, fuel_type: str): + """Return the price of the given fuel type.""" + if self._data is None: + return None + return next((price for price + in self._data if price.fuel_type == fuel_type), None) + + def get_available_fuel_types(self): + """Return the available fuel types for the station.""" + return [price.fuel_type for price in self._data] + + def get_station_name(self) -> str: + """Return the name of the station.""" + if self._station_name is None: + name = None + if self._reference_data is not None: + name = next((station.name for station + in self._reference_data.stations + if station.code == self.station_id), None) + + self._station_name = name or 'station {}'.format(self.station_id) + + return self._station_name + + +class StationPriceSensor(Entity): + """Implementation of a sensor that reports the fuel price for a station.""" + + def __init__(self, station_data: StationPriceData, fuel_type: str): + """Initialize the sensor.""" + self._station_data = station_data + self._fuel_type = fuel_type + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return '{} {}'.format( + self._station_data.get_station_name(), self._fuel_type) + + @property + def state(self) -> Optional[float]: + """Return the state of the sensor.""" + price_info = self._station_data.for_fuel_type(self._fuel_type) + if price_info: + return price_info.price + + return None + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes of the device.""" + return { + ATTR_STATION_ID: self._station_data.station_id, + ATTR_STATION_NAME: self._station_data.get_station_name(), + ATTR_ATTRIBUTION: CONF_ATTRIBUTION + } + + @property + def unit_of_measurement(self) -> str: + """Return the units of measurement.""" + return 'ยข/L' + + def update(self): + """Update current conditions.""" + self._station_data.update() diff --git a/requirements_all.txt b/requirements_all.txt index c180c3a055d..dabecdacb2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,6 +597,9 @@ neurio==0.3.1 # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 +# homeassistant.components.sensor.nsw_fuel_station +nsw-fuel-api-client==1.0.10 + # homeassistant.components.nuheat nuheat==0.3.0 diff --git a/tests/components/sensor/test_nsw_fuel_station.py b/tests/components/sensor/test_nsw_fuel_station.py new file mode 100644 index 00000000000..1ee314d9eee --- /dev/null +++ b/tests/components/sensor/test_nsw_fuel_station.py @@ -0,0 +1,117 @@ +"""The tests for the NSW Fuel Station sensor platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.components import sensor +from homeassistant.setup import setup_component +from tests.common import ( + get_test_home_assistant, assert_setup_component, MockDependency) + +VALID_CONFIG = { + 'platform': 'nsw_fuel_station', + 'station_id': 350, + 'fuel_types': ['E10', 'P95'], +} + + +class MockPrice(): + """Mock Price implementation.""" + + def __init__(self, price, fuel_type, last_updated, + price_unit, station_code): + """Initialize a mock price instance.""" + self.price = price + self.fuel_type = fuel_type + self.last_updated = last_updated + self.price_unit = price_unit + self.station_code = station_code + + +class MockStation(): + """Mock Station implementation.""" + + def __init__(self, name, code): + """Initialize a mock Station instance.""" + self.name = name + self.code = code + + +class MockGetReferenceDataResponse(): + """Mock GetReferenceDataResponse implementation.""" + + def __init__(self, stations): + """Initialize a mock GetReferenceDataResponse instance.""" + self.stations = stations + + +class FuelCheckClientMock(): + """Mock FuelCheckClient implementation.""" + + def get_fuel_prices_for_station(self, station): + """Return a fake fuel prices response.""" + return [ + MockPrice( + price=150.0, + fuel_type='P95', + last_updated=None, + price_unit=None, + station_code=350 + ), + MockPrice( + price=140.0, + fuel_type='E10', + last_updated=None, + price_unit=None, + station_code=350 + ) + ] + + def get_reference_data(self): + """Return a fake reference data response.""" + return MockGetReferenceDataResponse( + stations=[ + MockStation(code=350, name="My Fake Station") + ] + ) + + +class TestNSWFuelStation(unittest.TestCase): + """Test the NSW Fuel Station sensor platform.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @MockDependency('nsw_fuel') + @patch('nsw_fuel.FuelCheckClient', new=FuelCheckClientMock) + def test_setup(self, mock_nsw_fuel): + """Test the setup with custom settings.""" + with assert_setup_component(1, sensor.DOMAIN): + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { + 'sensor': VALID_CONFIG})) + + fake_entities = [ + 'my_fake_station_p95', + 'my_fake_station_e10' + ] + + for entity_id in fake_entities: + state = self.hass.states.get('sensor.{}'.format(entity_id)) + self.assertIsNotNone(state) + + @MockDependency('nsw_fuel') + @patch('nsw_fuel.FuelCheckClient', new=FuelCheckClientMock) + def test_sensor_values(self, mock_nsw_fuel): + """Test retrieval of sensor values.""" + self.assertTrue(setup_component( + self.hass, sensor.DOMAIN, {'sensor': VALID_CONFIG})) + + self.assertEqual('140.0', self.hass.states.get( + 'sensor.my_fake_station_e10').state) + self.assertEqual('150.0', self.hass.states.get( + 'sensor.my_fake_station_p95').state)