diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..194875a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 88 +tab_width = 4 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..faf8fe5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,81 @@ + +## Breaking change + + + +## Proposed change + + + +## Type of change + + +- [ ] Dependency upgrade +- [ ] Bugfix (non-breaking change which fixes an issue) +- [ ] New feature (which adds functionality to an this integration) +- [ ] Breaking change (fix/feature causing existing functionality to break) +- [ ] Code quality improvements to existing code or addition of tests + +## Example entry for `configuration.yaml`: + + +```yaml +# Example configuration.yaml + +``` + +## Additional information + + +- This PR fixes or closes issue: fixes # +- This PR is related to issue: + +## Checklist + + +- [ ] The code change is tested and works locally. +- [ ] There is no commented out code in this PR. +- [ ] The code has been formatted using Black (`black --fast custom_components`) + +If user exposed functionality or configuration variables are added/changed: + +- [ ] Documentation added/updated to README.md + + diff --git a/.gitignore b/.gitignore index 92b9467..2b8545f 100644 --- a/.gitignore +++ b/.gitignore @@ -103,9 +103,6 @@ venv.bak/ # mypy .mypy_cache/ -/test*.py -/dev-config - # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider @@ -179,3 +176,8 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser + + + +dev-config +/test*.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 661d817..f89b335 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,58 @@ repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.4.1 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^(custom_components|script)/.+\.py$ + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.1 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.2 + files: ^(custom_components)/.+\.py$ + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + - id: trailing-whitespace + - id: no-commit-to-branch + args: + - --branch=master + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.23.0 + hooks: + - id: yamllint - repo: local hooks: + # Run mypy through our wrapper script in order to get the possible + # pyenv and/or virtualenv activated; it may not have been e.g. if + # committing from a GUI tool that was not launched from an activated + # shell. + - id: mypy + name: mypy + entry: script/run-in-env.sh mypy + language: script + types: [python] + require_serial: true + files: ^custom_components/.+\.py$ - id: update-tracker name: "Update Tracker" - entry: ./update_tracker.py + entry: script/update_tracker.py language: system - id: pylint name: pylint entry: python3 -m pylint.__main__ language: system types: [python] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 - hooks: - - id: check-json - - id: check-yaml - - id: trailing-whitespace diff --git a/custom_components/iaquk/__init__.py b/custom_components/iaquk/__init__.py index 3eb7a13..db9efdf 100644 --- a/custom_components/iaquk/__init__.py +++ b/custom_components/iaquk/__init__.py @@ -11,21 +11,53 @@ import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.const import CONF_NAME, CONF_SENSORS, \ - EVENT_HOMEASSISTANT_START, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, \ - TEMP_FAHRENHEIT, UNIT_NOT_RECOGNIZED_TEMPLATE, TEMPERATURE, STATE_UNKNOWN, \ - STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_NAME, + CONF_SENSORS, + EVENT_HOMEASSISTANT_START, + ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_NOT_RECOGNIZED_TEMPLATE, + TEMPERATURE, + STATE_UNKNOWN, + STATE_UNAVAILABLE, +) from homeassistant.core import callback, State from homeassistant.helpers import discovery from homeassistant.helpers.event import async_track_state_change from homeassistant.util.temperature import convert as convert_temperature -from .const import DOMAIN, VERSION, ISSUE_URL, SUPPORT_LIB_URL, CONF_SOURCES, \ - DATA_IAQUK, CONF_CO2, CONF_TEMPERATURE, CONF_HUMIDITY, CONF_TVOC, \ - LEVEL_INADEQUATE, LEVEL_POOR, LEVEL_FAIR, LEVEL_GOOD, LEVEL_EXCELLENT, \ - CONF_NO2, CONF_PM, CONF_CO, CONF_HCHO, CONF_RADON, UNIT_PPM, UNIT_PPB, \ - UNIT_UGM3, UNIT_MGM3, ATTR_SOURCES_USED, ATTR_SOURCES_SET, MWEIGTH_TVOC, \ - MWEIGTH_HCHO, MWEIGTH_CO, MWEIGTH_NO2, MWEIGTH_CO2 +from .const import ( + DOMAIN, + VERSION, + ISSUE_URL, + CONF_SOURCES, + CONF_CO2, + CONF_TEMPERATURE, + CONF_HUMIDITY, + CONF_TVOC, + LEVEL_INADEQUATE, + LEVEL_POOR, + LEVEL_FAIR, + LEVEL_GOOD, + LEVEL_EXCELLENT, + CONF_NO2, + CONF_PM, + CONF_CO, + CONF_HCHO, + CONF_RADON, + UNIT_PPM, + UNIT_PPB, + UNIT_UGM3, + ATTR_SOURCES_USED, + ATTR_SOURCES_SET, + MWEIGTH_TVOC, + MWEIGTH_HCHO, + MWEIGTH_CO, + MWEIGTH_NO2, + MWEIGTH_CO2, +) from .sensor import SENSORS _LOGGER = logging.getLogger(__name__) @@ -42,41 +74,45 @@ CONF_PM, ] -SOURCES_LISTS = [ - CONF_PM -] +SOURCES_LISTS = [CONF_PM] SOURCES_SCHEMA = vol.All( - vol.Schema({vol.Optional(src): ( - cv.entity_ids if src in SOURCES_LISTS else cv.entity_id) - for src in SOURCES}), - cv.has_at_least_one_key(*SOURCES) + vol.Schema( + { + vol.Optional(src): (cv.entity_ids if src in SOURCES_LISTS else cv.entity_id) + for src in SOURCES + } + ), + cv.has_at_least_one_key(*SOURCES), ) -IAQ_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_SOURCES): SOURCES_SCHEMA, - vol.Optional(CONF_SENSORS): - vol.All(cv.ensure_list, [vol.In(SENSORS)]), -}) +IAQ_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SOURCES): SOURCES_SCHEMA, + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]), + } +) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: cv.schema_with_slug_keys(IAQ_SCHEMA), -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: cv.schema_with_slug_keys(IAQ_SCHEMA)}, extra=vol.ALLOW_EXTRA +) def _deslugify(string): - return string.replace('_', ' ').title() + """Deslugify string.""" + return string.replace("_", " ").title() async def async_setup(hass, config): """Set up component.""" # Print startup message - _LOGGER.info('Version %s', VERSION) - _LOGGER.info('If you have ANY issues with this,' - ' please report them here: %s', ISSUE_URL) + _LOGGER.info("Version %s", VERSION) + _LOGGER.info( + "If you have ANY issues with this, please report them here: %s", ISSUE_URL + ) - hass.data.setdefault(DATA_IAQUK, {}) + hass.data.setdefault(DOMAIN, {}) for object_id, cfg in config[DOMAIN].items(): if not cfg: @@ -89,19 +125,20 @@ async def async_setup(hass, config): if not sensors: sensors = SENSORS.keys() - _LOGGER.debug('Initialize controller %s for sources: %s', object_id, - ', '.join([f'{key}={value}' for (key, value) in - sources.items()])) + _LOGGER.debug( + "Initialize controller %s for sources: %s", + object_id, + ", ".join([f"{key}={value}" for (key, value) in sources.items()]), + ) controller = Iaquk(hass, object_id, name, sources) - hass.data[DATA_IAQUK][object_id] = controller + hass.data[DOMAIN][object_id] = controller - discovery.load_platform(hass, SENSOR, DOMAIN, { - CONF_NAME: object_id, - CONF_SENSORS: sensors, - }, config) + discovery.load_platform( + hass, SENSOR, DOMAIN, {CONF_NAME: object_id, CONF_SENSORS: sensors}, config + ) - if not hass.data[DATA_IAQUK]: + if not hass.data[DOMAIN]: return False return True @@ -123,7 +160,6 @@ def __init__(self, hass, entity_id: str, name: str, sources): def async_added_to_hass(self): """Register callbacks.""" - # pylint: disable=unused-argument @callback def sensor_state_listener(entity, old_state, new_state): @@ -141,17 +177,18 @@ def sensor_startup(event): else: entity_ids.append(src) - _LOGGER.debug('[%s] Setup states tracking for %s', self._entity_id, - ', '.join(entity_ids)) + _LOGGER.debug( + "[%s] Setup states tracking for %s", + self._entity_id, + ", ".join(entity_ids), + ) - async_track_state_change(self._hass, entity_ids, - sensor_state_listener) + async_track_state_change(self._hass, entity_ids, sensor_state_listener) sensor_state_listener(None, None, None) # Force first update if not self._added: self._added = True - self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, - sensor_startup) + self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, sensor_startup) @property def unique_id(self) -> str: @@ -197,83 +234,96 @@ def state_attributes(self) -> Optional[Dict[str, Any]]: def update(self): """Update index state.""" - _LOGGER.debug('[%s] State update', self._entity_id) + _LOGGER.debug("[%s] State update", self._entity_id) iaq = 0 sources = 0 for src in self._sources: - index = self.__getattribute__('_%s_index' % src) - _LOGGER.debug('[%s] %s_index=%s', self._entity_id, src, index) - if index is not None: - iaq += index - sources += 1 + try: + index = self.__getattribute__("_%s_index" % src) + _LOGGER.debug("[%s] %s_index=%s", self._entity_id, src, index) + if index is not None: + iaq += index + sources += 1 + except Exception: # pylint: disable=broad-except + pass if iaq: self._iaq_index = int((65 * iaq) / (5 * sources)) self._iaq_sources = int(sources) - _LOGGER.debug('[%s] Update IAQ index to %d (%d sources used)', - self._entity_id, self._iaq_index, self._iaq_sources) + _LOGGER.debug( + "[%s] Update IAQ index to %d (%d sources used)", + self._entity_id, + self._iaq_index, + self._iaq_sources, + ) @staticmethod def _has_state(state) -> bool: """Return True if state has any value.""" - return \ - state is not None \ - and state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] + return state is not None and state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] - def _get_number_state(self, entity_id, entity_unit=None, source_type='', - mweight=None) -> Optional[float]: + def _get_number_state( + self, entity_id, entity_unit=None, source_type="", mweight=None + ) -> float: """Convert value to number.""" target_unit = None if entity_unit is not None and not isinstance(entity_unit, dict): - entity_unit = { - entity_unit: 1 - } + entity_unit = {entity_unit: 1} entity = self._hass.states.get(entity_id) if not isinstance(entity, State): - return None + raise ValueError value = entity.state unit = entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - _LOGGER.debug('[%s] %s=%s %s', self._entity_id, entity_id, value, - (unit if unit and self._has_state(value) else '')) + _LOGGER.debug( + "[%s] %s=%s %s", + self._entity_id, + entity_id, + value, + (unit if unit and self._has_state(value) else ""), + ) if not self._has_state(value): - return None + raise ValueError if entity_unit is not None: target_unit = next(iter(entity_unit)) if unit not in entity_unit: # pylint: disable=R1705 if mweight is None: - _LOGGER.error('Entity %s has inappropriate "%s" units ' - 'for %s source. Ignored.', entity_id, unit, - source_type) - return None + _LOGGER.error( + 'Entity %s has inappropriate "%s" units ' + "for %s source. Ignored.", + entity_id, + unit, + source_type, + ) + raise ValueError + entity_unit = entity_unit.copy() + if "ppb" in (unit, target_unit): + mweight /= 1000 + if unit in {"ppm", "ppb"}: + entity_unit[unit] = mweight / 24.45 else: - entity_unit = entity_unit.copy() - if 'ppb' in (unit, target_unit): - mweight /= 1000 - if unit in {'ppm', 'ppb'}: - entity_unit[unit] = mweight / 24.45 - else: - entity_unit[unit] = 24.45 / mweight - - try: - value = float(value) - except: # pylint: disable=W0702 - return None + entity_unit[unit] = 24.45 / mweight + + value = float(value) if entity_unit is not None and unit != target_unit: value *= entity_unit[unit] - _LOGGER.debug('[%s] %s=%s %s (converted)', self._entity_id, - entity_id, value, target_unit) + _LOGGER.debug( + "[%s] %s=%s %s (converted)", + self._entity_id, + entity_id, + value, + target_unit, + ) return value @property def _temperature_index(self) -> Optional[int]: - """Transform indoor temperature values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor temperature values to IAQ points.""" entity_id = self._sources.get(CONF_TEMPERATURE) if entity_id is None: @@ -281,18 +331,15 @@ def _temperature_index(self) -> Optional[int]: entity = self._hass.states.get(entity_id) value = self._get_number_state(entity_id) - if value is None: - return None entity_unit = entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if entity_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT): raise ValueError( - UNIT_NOT_RECOGNIZED_TEMPLATE.format(entity_unit, - TEMPERATURE)) + UNIT_NOT_RECOGNIZED_TEMPLATE.format(entity_unit, TEMPERATURE) + ) if entity_unit != TEMP_CELSIUS: - value = convert_temperature( - value, entity_unit, TEMP_CELSIUS) + value = convert_temperature(value, entity_unit, TEMP_CELSIUS) # _LOGGER.debug('[%s] temperature=%s %s', self._entity_id, value, # TEMP_CELSIUS) @@ -310,14 +357,13 @@ def _temperature_index(self) -> Optional[int]: @property def _humidity_index(self) -> Optional[int]: - """Transform indoor humidity values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor humidity values to IAQ points.""" entity_id = self._sources.get(CONF_HUMIDITY) if entity_id is None: return None - value = self._get_number_state(entity_id, '%', CONF_HUMIDITY) + value = self._get_number_state(entity_id, "%", CONF_HUMIDITY) if value is None: return None @@ -336,15 +382,15 @@ def _humidity_index(self) -> Optional[int]: @property def _co2_index(self) -> Optional[int]: - """Transform indoor eCO2 values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor eCO2 values to IAQ points.""" entity_id = self._sources.get(CONF_CO2) if entity_id is None: return None value = self._get_number_state( - entity_id, UNIT_PPM, CONF_CO2, mweight=MWEIGTH_CO2) + entity_id, UNIT_PPM, CONF_CO2, mweight=MWEIGTH_CO2 + ) if value is None: return None @@ -363,15 +409,15 @@ def _co2_index(self) -> Optional[int]: @property def _tvoc_index(self) -> Optional[int]: - """Transform indoor tVOC values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor tVOC values to IAQ points.""" entity_id = self._sources.get(CONF_TVOC) if entity_id is None: return None value = self._get_number_state( - entity_id, UNIT_PPB, CONF_TVOC, mweight=MWEIGTH_TVOC) + entity_id, UNIT_PPB, CONF_TVOC, mweight=MWEIGTH_TVOC + ) if value is None: return None @@ -390,8 +436,7 @@ def _tvoc_index(self) -> Optional[int]: @property def _pm_index(self) -> Optional[int]: - """Transform indoor particulate matters values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor particulate matters values to IAQ points.""" entity_ids = self._sources.get(CONF_PM) if entity_ids is None: @@ -421,15 +466,15 @@ def _pm_index(self) -> Optional[int]: @property def _no2_index(self) -> Optional[int]: - """Transform indoor NO2 values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor NO2 values to IAQ points.""" entity_id = self._sources.get(CONF_NO2) if entity_id is None: return None value = self._get_number_state( - entity_id, UNIT_PPB, CONF_NO2, mweight=MWEIGTH_NO2) + entity_id, UNIT_PPB, CONF_NO2, mweight=MWEIGTH_NO2 + ) if value is None: return None @@ -444,15 +489,13 @@ def _no2_index(self) -> Optional[int]: @property def _co_index(self) -> Optional[int]: - """Transform indoor CO values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor CO values to IAQ points.""" entity_id = self._sources.get(CONF_CO) if entity_id is None: return None - value = self._get_number_state( - entity_id, UNIT_PPM, CONF_CO, mweight=MWEIGTH_CO) + value = self._get_number_state(entity_id, UNIT_PPM, CONF_CO, mweight=MWEIGTH_CO) if value is None: return None @@ -467,15 +510,15 @@ def _co_index(self) -> Optional[int]: @property def _hcho_index(self) -> Optional[int]: - """Transform indoor Formaldehyde (HCHO) values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor Formaldehyde (HCHO) values to IAQ points.""" entity_id = self._sources.get(CONF_HCHO) if entity_id is None: return None value = self._get_number_state( - entity_id, UNIT_PPB, CONF_HCHO, mweight=MWEIGTH_HCHO) + entity_id, UNIT_PPB, CONF_HCHO, mweight=MWEIGTH_HCHO + ) if value is None: return None @@ -494,14 +537,13 @@ def _hcho_index(self) -> Optional[int]: @property def _radon_index(self): - """Transform indoor Radon (Rn) values to IAQ points according - to Indoor Air Quality UK: http://www.iaquk.org.uk/ """ + """Transform indoor Radon (Rn) values to IAQ points.""" entity_id = self._sources.get(CONF_RADON) if entity_id is None: return None - value = self._get_number_state(entity_id, 'Bq/m3') + value = self._get_number_state(entity_id, "Bq/m3") if value is None: return None diff --git a/custom_components/iaquk/const.py b/custom_components/iaquk/const.py index ba23578..16d8591 100644 --- a/custom_components/iaquk/const.py +++ b/custom_components/iaquk/const.py @@ -2,12 +2,9 @@ # Base component constants DOMAIN = "iaquk" -VERSION = "1.3.1" +VERSION = '1.3.2' ISSUE_URL = "https://github.com/Limych/ha-iaquk/issues" ATTRIBUTION = None -DATA_IAQUK = 'iaquk' - -SUPPORT_LIB_URL = "https://github.com/Limych/iaquk/issues/new/choose" CONF_SOURCES = "sources" CONF_TEMPERATURE = "temperature" @@ -20,8 +17,8 @@ CONF_HCHO = "hcho" # Formaldehyde CONF_RADON = "radon" -ATTR_SOURCES_SET = 'sources_set' -ATTR_SOURCES_USED = 'sources_used' +ATTR_SOURCES_SET = "sources_set" +ATTR_SOURCES_USED = "sources_used" LEVEL_EXCELLENT = "Excellent" LEVEL_GOOD = "Good" @@ -30,32 +27,32 @@ LEVEL_INADEQUATE = "Inadequate" UNIT_PPM = { - 'ppm': 1, # Target unit -- conversion rate will be ignored - 'ppb': 0.001, + "ppm": 1, # Target unit -- conversion rate will be ignored + "ppb": 0.001, } UNIT_PPB = { - 'ppb': 1, # Target unit -- conversion rate will be ignored - 'ppm': 1000, + "ppb": 1, # Target unit -- conversion rate will be ignored + "ppm": 1000, } UNIT_UGM3 = { - 'µg/m³': 1, # Target unit -- conversion rate will be ignored - 'µg/m3': 1, - 'µg/m^3': 1, - 'mg/m³': 0.001, - 'mg/m3': 0.001, - 'mg/m^3': 0.001, + "µg/m³": 1, # Target unit -- conversion rate will be ignored + "µg/m3": 1, + "µg/m^3": 1, + "mg/m³": 0.001, + "mg/m3": 0.001, + "mg/m^3": 0.001, } UNIT_MGM3 = { - 'mg/m³': 1, # Target unit -- conversion rate will be ignored - 'mg/m3': 1, - 'mg/m^3': 1, - 'µg/m³': 1000, - 'µg/m3': 1000, - 'µg/m^3': 1000, + "mg/m³": 1, # Target unit -- conversion rate will be ignored + "mg/m3": 1, + "mg/m^3": 1, + "µg/m³": 1000, + "µg/m3": 1000, + "µg/m^3": 1000, } -MWEIGTH_TVOC = 56.1060 # g/mol -MWEIGTH_HCHO = 30.0260 # g/mol -MWEIGTH_CO = 28.0100 # g/mol -MWEIGTH_NO2 = 46.0100 # g/mol -MWEIGTH_CO2 = 44.0100 # g/mol +MWEIGTH_TVOC = 56.1060 # g/mol +MWEIGTH_HCHO = 30.0260 # g/mol +MWEIGTH_CO = 28.0100 # g/mol +MWEIGTH_NO2 = 46.0100 # g/mol +MWEIGTH_CO2 = 44.0100 # g/mol diff --git a/custom_components/iaquk/sensor.py b/custom_components/iaquk/sensor.py index ef621a3..35f59ce 100644 --- a/custom_components/iaquk/sensor.py +++ b/custom_components/iaquk/sensor.py @@ -7,34 +7,31 @@ from homeassistant.const import CONF_SENSORS, CONF_NAME from homeassistant.helpers.entity import Entity, async_generate_entity_id -from .const import DATA_IAQUK, LEVEL_INADEQUATE, LEVEL_POOR, LEVEL_GOOD, \ - LEVEL_EXCELLENT +from .const import DOMAIN, LEVEL_INADEQUATE, LEVEL_POOR, LEVEL_GOOD, LEVEL_EXCELLENT _LOGGER = logging.getLogger(__name__) -SENSOR_INDEX = 'iaq_index' -SENSOR_LEVEL = 'iaq_level' +SENSOR_INDEX = "iaq_index" +SENSOR_LEVEL = "iaq_level" SENSORS = { - SENSOR_INDEX: 'Indoor Air Quality Index', - SENSOR_LEVEL: 'Indoor Air Quality Level', + SENSOR_INDEX: "Indoor Air Quality Index", + SENSOR_LEVEL: "Indoor Air Quality Level", } # pylint: disable=w0613 -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a sensors to calculate IAQ UK index.""" if discovery_info is None: return object_id = discovery_info[CONF_NAME] - controller = hass.data[DATA_IAQUK][object_id] + controller = hass.data[DOMAIN][object_id] sensors = [] for sensor_type in discovery_info[CONF_SENSORS]: - _LOGGER.debug('Initialize sensor %s for controller %s', sensor_type, - object_id) + _LOGGER.debug("Initialize sensor %s for controller %s", sensor_type, object_id) sensors.append(IaqukSensor(hass, controller, sensor_type)) async_add_entities(sensors, True) @@ -44,16 +41,16 @@ class IaqukSensor(Entity): """IAQ UK sensor.""" def __init__(self, hass, controller, sensor_type: str): + """Initialize sensor.""" self._hass = hass self._controller = controller self._sensor_type = sensor_type - self._unique_id = "%s_%s" % ( - self._controller.unique_id, self._sensor_type) - self._name = "%s %s" % ( - self._controller.name, SENSORS[self._sensor_type]) + self._unique_id = f"{self._controller.unique_id}_{self._sensor_type}" + self._name = "{} {}".format(self._controller.name, SENSORS[self._sensor_type]) self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, self._unique_id, hass=hass) + ENTITY_ID_FORMAT, self._unique_id, hass=hass + ) async def async_added_to_hass(self): """Register callbacks.""" @@ -90,15 +87,18 @@ def icon(self) -> Optional[str]: @property def state(self) -> Union[None, str, int, float]: """Return the state of the sensor.""" - return self._controller.iaq_index if self._sensor_type == SENSOR_INDEX \ + return ( + self._controller.iaq_index + if self._sensor_type == SENSOR_INDEX else self._controller.iaq_level + ) @property def unit_of_measurement(self) -> Optional[str]: """Return the unit of measurement of this entity, if any.""" - return 'IAQI' if self._sensor_type == SENSOR_INDEX \ - else None + return "IAQI" if self._sensor_type == SENSOR_INDEX else None @property def state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes.""" return self._controller.state_attributes diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..1369c36 --- /dev/null +++ b/pylintrc @@ -0,0 +1,63 @@ +[MASTER] +ignore=tests +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +jobs=2 +load-plugins=pylint_strict_informational +persistent=no +extension-pkg-whitelist=ciso8601 + +[BASIC] +good-names=id,i,j,k,ex,Run,_,fp,T + +[MESSAGES CONTROL] +# Reasons disabled: +# format - handled by black +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +disable= + format, + abstract-class-little-used, + abstract-method, + cyclic-import, + duplicate-code, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unused-argument, + wrong-import-order +enable= + use-symbolic-message-instead + +[REPORTS] +score=no + +[TYPECHECK] +# For attrs +ignored-classes=_CountingAttr + +[FORMAT] +expected-line-ending-format=LF + +[EXCEPTIONS] +overgeneral-exceptions=BaseException,Exception,HomeAssistantError diff --git a/script/__init__.py b/script/__init__.py new file mode 100755 index 0000000..da52cab --- /dev/null +++ b/script/__init__.py @@ -0,0 +1 @@ +"""Development support scripts.""" diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..e4a0e8e --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,12 @@ +#!/bin/sh +# Resolve all dependencies that the application requires to run. + +# Stop on errors +set -e + +ROOT="$( cd "$( dirname "$(readlink -f "$0")" )/.." >/dev/null 2>&1 && pwd )" + +cd "${ROOT}" + +echo "Installing development dependencies..." +python3 -m pip install tox colorlog pre-commit $(grep mypy requirements-dev.txt) diff --git a/dev-test-deploy.sh b/script/dev-deploy old mode 100644 new mode 100755 similarity index 93% rename from dev-test-deploy.sh rename to script/dev-deploy index c32bbf5..735ed0d --- a/dev-test-deploy.sh +++ b/script/dev-deploy @@ -32,7 +32,7 @@ die() { -ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." >/dev/null 2>&1 && pwd )" HASSIO_CONFIG="${ROOT}/dev-config" diff --git a/script/run-in-env.sh b/script/run-in-env.sh new file mode 100755 index 0000000..d9fe17f --- /dev/null +++ b/script/run-in-env.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh -eu + +# Activate pyenv and virtualenv if present, then run the specified command + +# pyenv, pyenv-virtualenv +if [ -s .python-version ]; then + PYENV_VERSION=$(head -n 1 .python-version) + export PYENV_VERSION +fi + +# other common virtualenvs +for venv in venv .venv .; do + if [ -f $venv/bin/activate ]; then + . $venv/bin/activate + fi +done + +exec "$@" diff --git a/dev-setup.sh b/script/setup similarity index 53% rename from dev-setup.sh rename to script/setup index fde4a44..b357de2 100755 --- a/dev-setup.sh +++ b/script/setup @@ -1,10 +1,15 @@ #!/bin/sh +# Setups the repository. + +# Stop on errors +set -e + +ROOT="$( cd "$( dirname "$(readlink -f "$0")" )/.." >/dev/null 2>&1 && pwd )" + +cd "${ROOT}" +script/bootstrap -pip3 install pre-commit -pip3 install -r requirements.txt -r requirements-dev.txt --user pre-commit install -pre-commit autoupdate -chmod a+x update_tracker.py CONFIG=/run/user/$(id -u)/gvfs/smb-share:server=hassio,share=config/ if [ -d "$CONFIG" ] && [ ! -e dev-config ]; then diff --git a/script/update b/script/update new file mode 100755 index 0000000..096f43a --- /dev/null +++ b/script/update @@ -0,0 +1,11 @@ +#!/bin/sh +# Update application to run for its current checkout. + +# Stop on errors +set -e + +ROOT="$( cd "$( dirname "$(readlink -f "$0")" )/.." >/dev/null 2>&1 && pwd )" + +cd "${ROOT}" +git pull +git submodule update diff --git a/update_tracker.py b/script/update_tracker.py similarity index 55% rename from update_tracker.py rename to script/update_tracker.py index 5d426a7..b5c141b 100755 --- a/update_tracker.py +++ b/script/update_tracker.py @@ -12,23 +12,30 @@ # http://docs.python.org/2/howto/logging.html#library-config # Avoids spurious error messages if no logger is configured by the user +import sys + logging.getLogger(__name__).addHandler(logging.NullHandler()) # logging.basicConfig(level=logging.DEBUG) _LOGGER = logging.getLogger(__name__) -TRACKER_FPATH = 'custom_components.json' if os.path.isfile('custom_components.json') \ - else 'tracker.json' +ROOT = os.path.dirname(os.path.abspath(f"{__file__}/..")) +TRACKER_FPATH = ( + f"{ROOT}/custom_components.json" + if os.path.isfile(f"{ROOT}/custom_components.json") + else f"{ROOT}/tracker.json" +) + +sys.path.append(ROOT) def fallback_version(localpath): """Return version from regex match.""" - return_value = '' + return_value = "" if os.path.isfile(localpath): - with open(localpath, 'r') as local: - ret = re.compile( - r"^\b(VERSION|__version__)\s*=\s*['\"](.*)['\"]") + with open(localpath) as local: + ret = re.compile(r"^\b(VERSION|__version__)\s*=\s*['\"](.*)['\"]") for line in local.readlines(): matcher = ret.match(line) if matcher: @@ -36,28 +43,27 @@ def fallback_version(localpath): return return_value -def get_component_version(localpath, name): +def get_component_version(localpath, package): """Return the local version if any.""" - _LOGGER.debug('Started for %s', localpath) - if '.' in name: - name = "{}.{}".format(name.split('.')[1], name.split('.')[0]) - return_value = '' + if "." in package: + package = "{}.{}".format(package.split(".")[1], package.split(".")[0]) + package = f"custom_components.{package}" + _LOGGER.debug("Started for %s (%s)", localpath, package) + return_value = "" if os.path.isfile(localpath): - package = "custom_components.{}".format(name) + _LOGGER.debug(package) try: name = "__version__" - return_value = getattr( - __import__(package, fromlist=[name]), name) - except Exception as err: # pylint: disable=W0703 + return_value = getattr(__import__(package, fromlist=[name]), name) + except Exception as err: # pylint: disable=broad-except _LOGGER.debug(str(err)) - if return_value == '': + if return_value == "": try: name = "VERSION" - return_value = getattr( - __import__(package, fromlist=[name]), name) - except Exception as err: # pylint: disable=W0703 + return_value = getattr(__import__(package, fromlist=[name]), name) + except Exception as err: # pylint: disable=broad-except _LOGGER.debug(str(err)) - if return_value == '': + if return_value == "": return_value = fallback_version(localpath) _LOGGER.debug(str(return_value)) return return_value @@ -65,29 +71,30 @@ def get_component_version(localpath, name): def update_tracker(tracker_fpath): """Run tracker file update.""" - with open(tracker_fpath, 'r') as tracker_file: + with open(tracker_fpath) as tracker_file: tracker = json.load(tracker_file) old_tr = copy.deepcopy(tracker) for package in tracker: - _LOGGER.info('Updating version for %s', package) - local_path = tracker[package]['local_location'].lstrip('/\\') - tracker[package]['version'] = \ - get_component_version(local_path, package) + _LOGGER.info("Updating version for %s", package) + local_path = "{}/{}".format( + ROOT, tracker[package]["local_location"].lstrip("/\\") + ) + tracker[package]["version"] = get_component_version(local_path, package) base_path = os.path.split(local_path)[0] - base_url = os.path.split(tracker[package]['remote_location'])[0] + base_url = os.path.split(tracker[package]["remote_location"])[0] resources = [] for current_path, _, files in os.walk(base_path): - if current_path.find('__pycache__') != -1: + if current_path.find("__pycache__") != -1: continue for file in files: - file = os.path.join(current_path, file).replace('\\', '/') + file = os.path.join(current_path, file).replace("\\", "/") if file != local_path: - resources.append(base_url + file[len(base_path):]) + resources.append(base_url + file[len(base_path) :]) resources.sort() - tracker[package]['resources'] = resources + tracker[package]["resources"] = resources if tracker != old_tr: - with open(tracker_fpath, 'w') as tracker_file: + with open(tracker_fpath, "w") as tracker_file: json.dump(tracker, tracker_file, indent=4) diff --git a/script/version_bump.py b/script/version_bump.py new file mode 100755 index 0000000..1f1c08b --- /dev/null +++ b/script/version_bump.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Helper script to bump the current version.""" +import argparse +import logging +import os +import sys +from datetime import datetime +import re +import subprocess + +from packaging.version import Version + +# http://docs.python.org/2/howto/logging.html#library-config +# Avoids spurious error messages if no logger is configured by the user +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +# logging.basicConfig(level=logging.DEBUG) + +_LOGGER = logging.getLogger(__name__) + +ROOT = os.path.dirname(os.path.abspath(f"{__file__}/..")) + +sys.path.append(ROOT) + + +def fallback_version(localpath): + """Return version from regex match.""" + return_value = "" + if os.path.isfile(localpath): + with open(localpath) as local: + ret = re.compile(r"^\b(VERSION|__version__)\s*=\s*['\"](.*)['\"]") + for line in local.readlines(): + matcher = ret.match(line) + if matcher: + return_value = str(matcher.group(2)) + return return_value + + +def get_component_version(localpath, package): + """Return the local version if any.""" + if "." in package: + package = "{}.{}".format(package.split(".")[1], package.split(".")[0]) + package = f"custom_components.{package}" + _LOGGER.debug("Started for %s (%s)", localpath, package) + return_value = "" + if os.path.isfile(localpath): + try: + name = "__version__" + return_value = getattr(__import__(package, fromlist=[name]), name) + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug(str(err)) + if return_value == "": + try: + name = "VERSION" + return_value = getattr(__import__(package, fromlist=[name]), name) + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug(str(err)) + if return_value == "": + return_value = fallback_version(localpath) + _LOGGER.debug(str(return_value)) + return return_value + + +def _bump_release(release, bump_type): + """Bump a release tuple consisting of 3 numbers.""" + major, minor, patch = release + + if bump_type == "patch": + patch += 1 + elif bump_type == "minor": + minor += 1 + patch = 0 + + return major, minor, patch + + +def bump_version(version, bump_type): + """Return a new version given a current version and action.""" + to_change = {} + + if bump_type == "minor": + # Convert 0.67.3 to 0.68.0 + # Convert 0.67.3.b5 to 0.68.0 + # Convert 0.67.3.dev0 to 0.68.0 + # Convert 0.67.0.b5 to 0.67.0 + # Convert 0.67.0.dev0 to 0.67.0 + to_change["dev"] = None + to_change["pre"] = None + + if not version.is_prerelease or version.release[2] != 0: + to_change["release"] = _bump_release(version.release, "minor") + + elif bump_type == "patch": + # Convert 0.67.3 to 0.67.4 + # Convert 0.67.3.b5 to 0.67.3 + # Convert 0.67.3.dev0 to 0.67.3 + to_change["dev"] = None + to_change["pre"] = None + + if not version.is_prerelease: + to_change["release"] = _bump_release(version.release, "patch") + + elif bump_type == "dev": + # Convert 0.67.3 to 0.67.4.dev0 + # Convert 0.67.3.b5 to 0.67.4.dev0 + # Convert 0.67.3.dev0 to 0.67.3.dev1 + if version.is_devrelease: + to_change["dev"] = ("dev", version.dev + 1) + else: + to_change["pre"] = ("dev", 0) + to_change["release"] = _bump_release(version.release, "minor") + + elif bump_type == "beta": + # Convert 0.67.5 to 0.67.6b0 + # Convert 0.67.0.dev0 to 0.67.0b0 + # Convert 0.67.5.b4 to 0.67.5b5 + + if version.is_devrelease: + to_change["dev"] = None + to_change["pre"] = ("b", 0) + + elif version.is_prerelease: + if version.pre[0] == "a": + to_change["pre"] = ("b", 0) + if version.pre[0] == "b": + to_change["pre"] = ("b", version.pre[1] + 1) + else: + to_change["pre"] = ("b", 0) + to_change["release"] = _bump_release(version.release, "patch") + + else: + to_change["release"] = _bump_release(version.release, "patch") + to_change["pre"] = ("b", 0) + + elif bump_type == "nightly": + # Convert 0.70.0d0 to 0.70.0d20190424, fails when run on non dev release + if not version.is_devrelease: + raise ValueError("Can only be run on dev release") + + to_change["dev"] = ( + "dev", + datetime.utcnow().date().isoformat().replace("-", ""), + ) + + else: + assert False, f"Unsupported type: {bump_type}" + + temp = Version("0") + temp._version = version._version._replace( # pylint: disable=protected-access + **to_change + ) + return Version(str(temp)) + + +def write_version(component_path, version): + """Update custom component constant file with new version.""" + component_path += "/const.py" + with open(component_path) as fil: + content = fil.read() + + content = re.sub("VERSION = .*\n", f"VERSION = '{version}'\n", content) + content = re.sub("__version__ = .*\n", f"__version__ = '{version}'\n", content) + + with open(component_path, "wt") as fil: + fil.write(content) + + +def main(): + """Execute script.""" + parser = argparse.ArgumentParser( + description="Bump version of Home Assistant custom component" + ) + parser.add_argument( + "type", + help="The type of the bump the version to.", + choices=["beta", "dev", "patch", "minor", "nightly"], + ) + parser.add_argument( + "--commit", action="store_true", help="Create a version bump commit." + ) + arguments = parser.parse_args() + + # pylint: disable=subprocess-run-check + if arguments.commit and subprocess.run(["git", "diff", "--quiet"]).returncode == 1: + print("Cannot use --commit because git is dirty.") + return + + component = None + for current_path, dirs, _ in os.walk(f"{ROOT}/custom_components"): + if current_path.find("__pycache__") != -1: + continue + for dname in dirs: + if dname != "__pycache__": + component = dname + + assert component, "Component not found!" + component_path = f"{ROOT}/custom_components/{component}" + + current = Version(get_component_version(f"{component_path}/__init__.py", component)) + bumped = bump_version(current, arguments.type) + assert bumped > current, "BUG! New version is not newer than old version" + + write_version(component_path, bumped) + + if not arguments.commit: + return + + subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"]) + + +# pylint: disable=import-outside-toplevel +def test_bump_version(): + """Make sure it all works.""" + import pytest + + assert bump_version(Version("0.56.0"), "beta") == Version("0.56.1b0") + assert bump_version(Version("0.56.0b3"), "beta") == Version("0.56.0b4") + assert bump_version(Version("0.56.0.dev0"), "beta") == Version("0.56.0b0") + + assert bump_version(Version("0.56.3"), "dev") == Version("0.57.0.dev0") + assert bump_version(Version("0.56.0b3"), "dev") == Version("0.57.0.dev0") + assert bump_version(Version("0.56.0.dev0"), "dev") == Version("0.56.0.dev1") + + assert bump_version(Version("0.56.3"), "patch") == Version("0.56.4") + assert bump_version(Version("0.56.3.b3"), "patch") == Version("0.56.3") + assert bump_version(Version("0.56.0.dev0"), "patch") == Version("0.56.0") + + assert bump_version(Version("0.56.0"), "minor") == Version("0.57.0") + assert bump_version(Version("0.56.3"), "minor") == Version("0.57.0") + assert bump_version(Version("0.56.0.b3"), "minor") == Version("0.56.0") + assert bump_version(Version("0.56.3.b3"), "minor") == Version("0.57.0") + assert bump_version(Version("0.56.0.dev0"), "minor") == Version("0.56.0") + assert bump_version(Version("0.56.2.dev0"), "minor") == Version("0.57.0") + + today = datetime.utcnow().date().isoformat().replace("-", "") + assert bump_version(Version("0.56.0.dev0"), "nightly") == Version( + f"0.56.0.dev{today}" + ) + with pytest.raises(ValueError): + assert bump_version(Version("0.56.0"), "nightly") + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9769c0e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[flake8] +max-line-length = 88 +exclude = + .git, + __pycache__, + dev_config + +[mypy] +ignore_missing_imports = True