diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index 109eae9..12e1d0a 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -2,7 +2,11 @@ name: Validate with hassfest on: push: + branches: + - main pull_request: + branches: + - main schedule: - cron: "0 0 * * *" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..7f05972 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,20 @@ +name: Run pre-commit checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - name: Install dependencies + run: | + pip install -r requirements.test.txt + - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/pytest.yml similarity index 70% rename from .github/workflows/ci.yml rename to .github/workflows/pytest.yml index 806507b..87caa88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/pytest.yml @@ -1,22 +1,22 @@ -name: Test with pytest and flake8 +name: Test code with pytest on: push: - branches: [ main ] + branches: + - main pull_request: - branches: [ main ] + branches: + - main schedule: - - cron: '0 3 * * *' + - cron: '0 0 * * *' jobs: test: - runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10"] - steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -25,11 +25,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip pip install -r requirements.test.txt - - name: Lint with flake8 - run: | - flake8 + - name: Test with pytest run: | pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 263fdd4..3164377 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,10 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.3.0 + rev: v2.38.2 hooks: - id: pyupgrade args: [--py37-plus] + - repo: https://github.com/psf/black rev: 22.3.0 hooks: @@ -12,25 +13,26 @@ repos: - --safe - --quiet files: ^((custom_components|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/codespell-project/codespell - rev: v1.16.0 + rev: v2.0.0 hooks: - id: codespell args: - - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - - --skip="./.*,*.csv,*.json" + - --ignore-words-list=hass,spawnve - --quiet-level=2 - exclude_types: [csv, json] + - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.1 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: - flake8-docstrings==1.5.0 - pydocstyle==5.0.2 files: ^(custom_components|tests)/.+\.py$ + - repo: https://github.com/PyCQA/bandit - rev: 1.6.2 + rev: 1.7.0 hooks: - id: bandit args: @@ -38,16 +40,19 @@ repos: - --format=custom - --configfile=tests/bandit.yaml files: ^(custom_components||tests)/.+\.py$ + - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + rev: v5.10.1 hooks: - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + rev: v3.2.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: check-json + - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.941 hooks: @@ -56,3 +61,13 @@ repos: - --pretty - --show-error-codes - --show-error-context + + - repo: local + hooks: + - id: pylint + name: pylint + entry: pylint + language: system + files: ^((custom_components|tests)/.+)?[^/]+\.py$ + + diff --git a/README.md b/README.md index c3b91bb..74d0647 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,13 @@ Subaru integration options are set via: All options involve remote commands, thus only apply to vehicles with Security Plus subscriptions: - **Enable vehicle polling:** Sensor data reported by the Subaru API only returns what is cached on Subaru servers, and does not necessarily reflect current conditions. The cached data is updated when the engine is shutdown, or when a location update is requested. This options enables automatic periodic updates. - - **Disabled *[Default]*:** New sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown). The user may still manually poll the vehicle anytime with the `subaru.update` service. - - **Enabled:** Every 2 hours, the integration will send a remote command (equivalent to running the `subaru.update` service), "waking" your vehicle obtain new sensor data. *WARNING:* Vehicle polling draws power from the 12V battery. Long term use without driving may drain the battery resulting in the inability to start your vehicle. + - **Disable *[Default]*:** New sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown). The user may still manually poll the vehicle anytime with the Locate button. + - **Charging:** For PHEVs, during charging, the integration will poll every 30 minutes to obtain updated charging status. Polling will only occur during charging. + - **Enable:** Every 2 hours, the integration will send a remote command (equivalent to pressing the Locate button), "waking" your vehicle obtain new sensor data. *WARNING:* Vehicle polling draws power from the 12V battery. Long term use without driving may drain the battery resulting in the inability to start your vehicle. - **Lovelace UI notifications for remote commands:** It takes 10-15 seconds for remote commands to be processed by the Subaru API and transmitted over the cellular network to your vehicle. Some users may desire UI feedback that the integration is working. This option provides three levels of increasing verbosity: - - **Failure *[Default]*:** Only notify when the remote command has failed. + - **Disable *[Default]*:** Lovelace notifications are disabled. Errors will still be logged. + - **Failure :** Only notify when the remote command has failed. - **Pending:** Failure + temporary notification that the command is "working" that will automatically disappear when the Subaru API confirms success (10 to 15 seconds). - **Success:** Pending + persistent notification of success in Lovelace. This is the same behavior as v0.5.1 and earlier releases. diff --git a/custom_components/__init__.py b/custom_components/__init__.py index e69de29..9e13e2f 100644 --- a/custom_components/__init__.py +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Subaru custom component for Home Assistant.""" diff --git a/custom_components/subaru/__init__.py b/custom_components/subaru/__init__.py index 4ea0c72..7985d93 100644 --- a/custom_components/subaru/__init__.py +++ b/custom_components/subaru/__init__.py @@ -7,6 +7,10 @@ from subarulink.const import COUNTRY_USA import voluptuous as vol +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, @@ -14,6 +18,8 @@ CONF_PASSWORD, CONF_PIN, CONF_USERNAME, + STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError @@ -21,6 +27,7 @@ aiohttp_client, config_validation as cv, device_registry, + entity_registry, ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -28,7 +35,7 @@ from .const import ( CONF_COUNTRY, CONF_NOTIFICATION_OPTION, - CONF_UPDATE_ENABLED, + CONF_POLLING_OPTION, COORDINATOR_NAME, DOMAIN, ENTRY_CONTROLLER, @@ -40,6 +47,7 @@ REMOTE_SERVICE_REMOTE_START, SUPPORTED_PLATFORMS, UPDATE_INTERVAL, + UPDATE_INTERVAL_CHARGING, VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_SERVICE, @@ -52,11 +60,12 @@ VEHICLE_NAME, VEHICLE_VIN, ) +from .options import PollingOptions from .remote_service import ( async_call_remote_service, get_supported_services, + poll_subaru, refresh_subaru, - update_subaru, ) _LOGGER = logging.getLogger(__name__) @@ -105,7 +114,7 @@ async def async_setup_entry(hass, entry): async def async_update_data(): """Fetch data from API endpoint.""" try: - return await refresh_subaru_data(entry, vehicles, controller) + return await refresh_subaru_data(hass, entry, vehicles, controller) except SubaruException as err: raise UpdateFailed(err.message) from err @@ -132,7 +141,7 @@ async def async_update_data(): async def async_call_service(call): """Execute subaru service.""" - _LOGGER.warn( + _LOGGER.warning( "This Subaru-specific service is deprecated and will be removed in v0.7.0. Use button or lock entities (or their respective services) to actuate remove vehicle services." ) vin = call.data[VEHICLE_VIN].upper() @@ -150,9 +159,6 @@ async def async_call_service(call): await coordinator.async_refresh() return - hass.components.persistent_notification.create( - f"ERROR - Invalid VIN provided while calling {call.service}", "Subaru" - ) raise HomeAssistantError(f"Invalid VIN provided while calling {call.service}") async def async_remote_start(call): @@ -219,7 +225,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -async def refresh_subaru_data(config_entry, vehicle_info, controller): +async def refresh_subaru_data(hass, config_entry, vehicle_info, controller): """ Refresh local data with data fetched via Subaru API. @@ -235,9 +241,31 @@ async def refresh_subaru_data(config_entry, vehicle_info, controller): if not vehicle[VEHICLE_HAS_SAFETY_SERVICE]: continue - # Send an "update" remote command to vehicle, if supported (throttled with update_interval) - if config_entry.options.get(CONF_UPDATE_ENABLED, False): - await update_subaru(vehicle, controller) + # Poll vehicle, if option is enabled + polling_option = PollingOptions.get_by_value( + config_entry.options.get(CONF_POLLING_OPTION, PollingOptions.DISABLE.value) + ) + if polling_option == PollingOptions.CHARGING: + # Is there a better way to check if the subaru is charging? + e_registry = entity_registry.async_get(hass) + battery_charging = e_registry.async_get_device_class_lookup( + {(Platform.BINARY_SENSOR, BinarySensorDeviceClass.BATTERY_CHARGING)} + ) + for item in battery_charging.values(): + entity_id = item[ + (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.BATTERY_CHARGING) + ] + entity = e_registry.async_get(entity_id) + state = hass.states.get(entity_id) + if entity and state: + if entity.platform == DOMAIN and state.state == STATE_ON: + await poll_subaru( + vehicle, + controller, + update_interval=UPDATE_INTERVAL_CHARGING, + ) + elif polling_option == PollingOptions.ENABLE: + await poll_subaru(vehicle, controller) # Fetch data from Subaru servers await refresh_subaru(vehicle, controller) diff --git a/custom_components/subaru/binary_sensor.py b/custom_components/subaru/binary_sensor.py index 01d4233..8331168 100644 --- a/custom_components/subaru/binary_sensor.py +++ b/custom_components/subaru/binary_sensor.py @@ -175,7 +175,11 @@ def _create_sensor_entities(entities, vehicle_info, coordinator): not in sc.BAD_BINARY_SENSOR_VALUES ): entities.append( - SubaruBinarySensor(vehicle_info, coordinator, sensor_description,) + SubaruBinarySensor( + vehicle_info, + coordinator, + sensor_description, + ) ) diff --git a/custom_components/subaru/button.py b/custom_components/subaru/button.py index bfe1e79..fa7f605 100644 --- a/custom_components/subaru/button.py +++ b/custom_components/subaru/button.py @@ -79,7 +79,11 @@ def create_vehicle_buttons(vehicle_info, coordinator, config_entry): return [ SubaruButton( - vehicle_info, config_entry, coordinator, b[BUTTON_TYPE], b[BUTTON_SERVICE], + vehicle_info, + config_entry, + coordinator, + b[BUTTON_TYPE], + b[BUTTON_SERVICE], ) for b in buttons_to_add ] diff --git a/custom_components/subaru/config_flow.py b/custom_components/subaru/config_flow.py index 58ef06c..545a006 100644 --- a/custom_components/subaru/config_flow.py +++ b/custom_components/subaru/config_flow.py @@ -14,15 +14,10 @@ from homeassistant import config_entries from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client, config_validation as cv - -from .const import ( - CONF_COUNTRY, - CONF_NOTIFICATION_OPTION, - CONF_UPDATE_ENABLED, - DOMAIN, - NotificationOptions, -) +from homeassistant.helpers import aiohttp_client + +from .const import CONF_COUNTRY, CONF_NOTIFICATION_OPTION, CONF_POLLING_OPTION, DOMAIN +from .options import NotificationOptions, PollingOptions _LOGGER = logging.getLogger(__name__) @@ -206,13 +201,15 @@ async def async_step_init(self, user_input=None): data_schema = vol.Schema( { vol.Required( - CONF_UPDATE_ENABLED, - default=self.config_entry.options.get(CONF_UPDATE_ENABLED, False), - ): cv.boolean, + CONF_POLLING_OPTION, + default=self.config_entry.options.get( + CONF_POLLING_OPTION, PollingOptions.DISABLE.value + ), + ): vol.In(sorted(PollingOptions.list())), vol.Required( CONF_NOTIFICATION_OPTION, default=self.config_entry.options.get( - CONF_NOTIFICATION_OPTION, NotificationOptions.FAILURE.value + CONF_NOTIFICATION_OPTION, NotificationOptions.DISABLE.value ), ): vol.In(sorted(NotificationOptions.list())), } diff --git a/custom_components/subaru/const.py b/custom_components/subaru/const.py index 5e26e37..d72d710 100644 --- a/custom_components/subaru/const.py +++ b/custom_components/subaru/const.py @@ -1,6 +1,4 @@ """Constants for the Subaru integration.""" -from enum import Enum - import subarulink.const as sc from homeassistant.const import Platform @@ -8,31 +6,11 @@ DOMAIN = "subaru" FETCH_INTERVAL = 300 UPDATE_INTERVAL = 7200 -CONF_UPDATE_ENABLED = "update_enabled" +UPDATE_INTERVAL_CHARGING = 1800 +CONF_POLLING_OPTION = "polling_option" CONF_NOTIFICATION_OPTION = "notification_option" CONF_COUNTRY = "country" - -class NotificationOptions(Enum): - """Lovelace levels of notification.""" - - FAILURE = "Failure — Only notify on failure" - PENDING = "Pending — Temporary notification of remote command in progress" - SUCCESS = "Success — Persistent notification of completed remote command" - - @classmethod - def list(cls): - """List values of NotificationOptions.""" - return [item.value for item in NotificationOptions] - - @classmethod - def get_by_value(cls, value): - """Get enum instance by value.""" - for item in cls: - if item.value == value: - return item - - # entry fields ENTRY_CONTROLLER = "controller" ENTRY_COORDINATOR = "coordinator" diff --git a/custom_components/subaru/manifest.json b/custom_components/subaru/manifest.json index 61b3742..ea8b308 100644 --- a/custom_components/subaru/manifest.json +++ b/custom_components/subaru/manifest.json @@ -4,8 +4,8 @@ "config_flow": true, "documentation": "https://github.com/G-Two/homeassistant-subaru", "issue_tracker": "https://github.com/G-Two/homeassistant-subaru/issues", - "requirements": ["subarulink==0.5.0"], + "requirements": ["subarulink==0.6.0"], "codeowners": ["@G-Two"], - "version": "0.6.2", + "version": "0.6.3", "iot_class": "cloud_polling" } diff --git a/custom_components/subaru/options.py b/custom_components/subaru/options.py new file mode 100644 index 0000000..f7e6353 --- /dev/null +++ b/custom_components/subaru/options.py @@ -0,0 +1,38 @@ +"""Enums for Subaru integration config options.""" + +from enum import Enum + + +class ConfigOptionsEnum(Enum): + """Base class for Config UI options enums.""" + + @classmethod + def list(cls): + """List values.""" + return [item.value for item in cls] + + @classmethod + def get_by_value(cls, value): + """Get enum instance by value.""" + result = None + for item in cls: + if item.value == value: + result = item + return result + + +class NotificationOptions(ConfigOptionsEnum): + """Lovelace levels of notification.""" + + DISABLE = "Disable — No notifications will appear" + FAILURE = "Failure — Only notify on failure" + PENDING = "Pending — Temporary notification of remote command in progress" + SUCCESS = "Success — Persistent notification of completed remote command" + + +class PollingOptions(ConfigOptionsEnum): + """Options for vehicle polling.""" + + DISABLE = "Disable — Do not poll vehicle (vehicle will still push update when engine is turned off)" + CHARGING = "Charge Only — Poll vehicle every 30 minutes during charging only" + ENABLE = "Enable — Poll vehicle every 2 hours" diff --git a/custom_components/subaru/remote_service.py b/custom_components/subaru/remote_service.py index 1e309ac..13739f8 100644 --- a/custom_components/subaru/remote_service.py +++ b/custom_components/subaru/remote_service.py @@ -28,8 +28,8 @@ VEHICLE_LAST_UPDATE, VEHICLE_NAME, VEHICLE_VIN, - NotificationOptions, ) +from .options import NotificationOptions _LOGGER = logging.getLogger(__name__) @@ -60,9 +60,7 @@ async def async_call_remote_service( err_msg = "" try: if cmd == REMOTE_SERVICE_UPDATE: - success = await update_subaru( - vehicle_info, controller, override_interval=True - ) + success = await poll_subaru(vehicle_info, controller, update_interval=0) elif cmd in [REMOTE_SERVICE_REMOTE_START, REMOTE_SERVICE_UNLOCK]: success = await getattr(controller, cmd)(vin, arg) elif cmd == REMOTE_SERVICE_FETCH: @@ -71,9 +69,7 @@ async def async_call_remote_service( success = await getattr(controller, cmd)(vin) if cmd in SERVICES_THAT_NEED_FETCH: - success = await refresh_subaru( - vehicle_info, controller, override_interval=True - ) + success = await refresh_subaru(vehicle_info, controller, refresh_interval=0) except SubaruException as err: err_msg = err.message @@ -84,14 +80,16 @@ async def async_call_remote_service( if success: if notify == NotificationOptions.SUCCESS: hass.components.persistent_notification.create( - f"{cmd} command successfully completed for {car_name}", "Subaru", + f"{cmd} command successfully completed for {car_name}", + "Subaru", ) _LOGGER.debug("%s command successfully completed for %s", cmd, car_name) return - hass.components.persistent_notification.create( - f"{cmd} command failed for {car_name}: {err_msg}", "Subaru" - ) + if notify != NotificationOptions.DISABLE: + hass.components.persistent_notification.create( + f"{cmd} command failed for {car_name}: {err_msg}", "Subaru" + ) raise HomeAssistantError(f"Service {cmd} failed for {car_name}: {err_msg}") @@ -118,27 +116,27 @@ def get_supported_services(vehicle_info): return remote_services -async def update_subaru(vehicle, controller, override_interval=False): +async def poll_subaru(vehicle, controller, update_interval=UPDATE_INTERVAL): """Commands remote vehicle update (polls the vehicle to update subaru API cache).""" cur_time = time.time() last_update = vehicle[VEHICLE_LAST_UPDATE] success = None - if (cur_time - last_update) > UPDATE_INTERVAL or override_interval: + if (cur_time - last_update) > update_interval: success = await controller.update(vehicle[VEHICLE_VIN], force=True) vehicle[VEHICLE_LAST_UPDATE] = cur_time return success -async def refresh_subaru(vehicle, controller, override_interval=False): +async def refresh_subaru(vehicle, controller, refresh_interval=FETCH_INTERVAL): """Refresh data from Subaru servers.""" cur_time = time.time() last_fetch = vehicle[VEHICLE_LAST_FETCH] vin = vehicle[VEHICLE_VIN] success = None - if (cur_time - last_fetch) > FETCH_INTERVAL or override_interval: + if (cur_time - last_fetch) > refresh_interval: success = await controller.fetch(vin, force=True) vehicle[VEHICLE_LAST_FETCH] = cur_time diff --git a/custom_components/subaru/sensor.py b/custom_components/subaru/sensor.py index b6dd050..5f3c147 100644 --- a/custom_components/subaru/sensor.py +++ b/custom_components/subaru/sensor.py @@ -1,5 +1,8 @@ """Support for Subaru sensors.""" +from __future__ import annotations + import logging +from typing import Any import subarulink.const as sc @@ -22,7 +25,10 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from homeassistant.util.distance import convert as dist_convert from homeassistant.util.unit_system import IMPERIAL_SYSTEM, LENGTH_UNITS, PRESSURE_UNITS from homeassistant.util.volume import convert as vol_convert @@ -36,42 +42,25 @@ VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_HAS_SAFETY_SERVICE, - VEHICLE_NAME, VEHICLE_STATUS, VEHICLE_VIN, ) _LOGGER = logging.getLogger(__name__) +# Fuel consumption units +FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS = "L/100km" +FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" + L_PER_GAL = vol_convert(1, VOLUME_GALLONS, VOLUME_LITERS) KM_PER_MI = dist_convert(1, LENGTH_MILES, LENGTH_KILOMETERS) -# Fuel Economy Constants -FUEL_CONSUMPTION_L_PER_100KM = "L/100km" -FUEL_CONSUMPTION_MPG = "mi/gal" -FUEL_CONSUMPTION_UNITS = [FUEL_CONSUMPTION_L_PER_100KM, FUEL_CONSUMPTION_MPG] - -SENSOR_KEY_TO_SUFFIX = { - sc.ODOMETER: "Odometer", - sc.AVG_FUEL_CONSUMPTION: "Avg Fuel Consumption", - sc.DIST_TO_EMPTY: "Range", - sc.TIRE_PRESSURE_FL: "Tire Pressure FL", - sc.TIRE_PRESSURE_FR: "Tire Pressure FR", - sc.TIRE_PRESSURE_RL: "Tire Pressure RL", - sc.TIRE_PRESSURE_RR: "Tire Pressure RR", - sc.EXTERNAL_TEMP: "External Temp", - sc.BATTERY_VOLTAGE: "12V Battery Voltage", - sc.EV_DISTANCE_TO_EMPTY: "EV Range", - sc.EV_STATE_OF_CHARGE_PERCENT: "EV Battery Level", - sc.EV_TIME_TO_FULLY_CHARGED_UTC: "EV Time to Full Charge", -} - # Sensor available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles SAFETY_SENSORS = [ SensorEntityDescription( key=sc.ODOMETER, - device_class=None, icon="mdi:road-variant", + name="Odometer", native_unit_of_measurement=LENGTH_KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -81,51 +70,57 @@ API_GEN_2_SENSORS = [ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, - device_class=None, icon="mdi:leaf", - native_unit_of_measurement=FUEL_CONSUMPTION_L_PER_100KM, + name="Avg Fuel Consumption", + native_unit_of_measurement=FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.DIST_TO_EMPTY, - device_class=None, icon="mdi:gas-station", + name="Range", native_unit_of_measurement=LENGTH_KILOMETERS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FL, device_class=SensorDeviceClass.PRESSURE, + name="Tire Pressure FL", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FR, device_class=SensorDeviceClass.PRESSURE, + name="Tire Pressure FR", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RL, device_class=SensorDeviceClass.PRESSURE, + name="Tire Pressure RL", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RR, device_class=SensorDeviceClass.PRESSURE, + name="Tire Pressure RR", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EXTERNAL_TEMP, device_class=SensorDeviceClass.TEMPERATURE, + name="External Temp", native_unit_of_measurement=TEMP_CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.BATTERY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, + name="12V Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=SensorStateClass.MEASUREMENT, ), @@ -135,21 +130,22 @@ EV_SENSORS = [ SensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, - device_class=None, icon="mdi:ev-station", + name="EV Range", native_unit_of_measurement=LENGTH_MILES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EV_STATE_OF_CHARGE_PERCENT, device_class=SensorDeviceClass.BATTERY, + name="EV Battery Level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EV_TIME_TO_FULLY_CHARGED_UTC, device_class=SensorDeviceClass.TIMESTAMP, - native_unit_of_measurement=None, + name="EV Time to Full Charge", state_class=SensorStateClass.MEASUREMENT, ), ] @@ -165,12 +161,14 @@ async def async_setup_entry( coordinator = entry[ENTRY_COORDINATOR] vehicle_info = entry[ENTRY_VEHICLES] entities = [] - for vin in vehicle_info: - entities.extend(create_vehicle_sensors(vehicle_info[vin], coordinator)) + for info in vehicle_info.values(): + entities.extend(create_vehicle_sensors(info, coordinator)) async_add_entities(entities) -def create_vehicle_sensors(vehicle_info, coordinator): +def create_vehicle_sensors( + vehicle_info, coordinator: DataUpdateCoordinator +) -> list[SubaruSensor]: """Instantiate all available sensors for the vehicle.""" sensor_descriptions_to_add = [] if vehicle_info[VEHICLE_HAS_SAFETY_SERVICE]: @@ -183,32 +181,40 @@ def create_vehicle_sensors(vehicle_info, coordinator): sensor_descriptions_to_add.extend(EV_SENSORS) return [ - SubaruSensor(vehicle_info, coordinator, description,) + SubaruSensor( + vehicle_info, + coordinator, + description, + ) for description in sensor_descriptions_to_add ] -class SubaruSensor(CoordinatorEntity, SensorEntity): +class SubaruSensor( + CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]], SensorEntity +): """Class for Subaru sensors.""" + _attr_has_entity_name = True + def __init__( - self, vehicle_info, coordinator, description, - ): + self, + vehicle_info: dict, + coordinator: DataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - suffix = SENSOR_KEY_TO_SUFFIX[description.key] self.vin = vehicle_info[VEHICLE_VIN] self.entity_description = description self._attr_device_info = get_device_info(vehicle_info) - self._attr_name = f"{vehicle_info[VEHICLE_NAME]} {suffix}" - self._attr_should_poll = False - self._attr_unique_id = f"{self.vin}_{suffix}" - _LOGGER.debug("Initialized SubaruSensor for %s", self._attr_name) + self._attr_unique_id = f"{self.vin}_{description.name}" @property - def native_value(self): + def native_value(self) -> None | int | float: """Return the state of the sensor.""" - current_value = self.get_current_value() + vehicle_data = self.coordinator.data[self.vin] + current_value = vehicle_data[VEHICLE_STATUS].get(self.entity_description.key) unit = self.entity_description.native_unit_of_measurement unit_system = self.hass.config.units @@ -219,15 +225,25 @@ def native_value(self): return round(unit_system.length(current_value, unit), 1) if unit in PRESSURE_UNITS and unit_system == IMPERIAL_SYSTEM: - return round(unit_system.pressure(current_value, unit), 1,) - - if unit in FUEL_CONSUMPTION_UNITS and unit_system == IMPERIAL_SYSTEM: + return round( + unit_system.pressure(current_value, unit), + 1, + ) + + if ( + unit + in [ + FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, + FUEL_CONSUMPTION_MILES_PER_GALLON, + ] + and unit_system == IMPERIAL_SYSTEM + ): return round((100.0 * L_PER_GAL) / (KM_PER_MI * current_value), 1) return current_value @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit_of_measurement of the device.""" unit = self.entity_description.native_unit_of_measurement @@ -238,32 +254,19 @@ def native_unit_of_measurement(self): if self.hass.config.units == IMPERIAL_SYSTEM: return self.hass.config.units.pressure_unit - if unit in FUEL_CONSUMPTION_UNITS: + if unit in [ + FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, + FUEL_CONSUMPTION_MILES_PER_GALLON, + ]: if self.hass.config.units == IMPERIAL_SYSTEM: - return FUEL_CONSUMPTION_MPG + return FUEL_CONSUMPTION_MILES_PER_GALLON return unit @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" last_update_success = super().available if last_update_success and self.vin not in self.coordinator.data: return False - if self.state is None: - return False return last_update_success - - def get_current_value(self): - """Get raw value from the coordinator.""" - if isinstance(data := self.coordinator.data, dict): - value = data.get(self.vin)[VEHICLE_STATUS].get(self.entity_description.key) - if value in sc.BAD_SENSOR_VALUES: - value = None - if isinstance(value, str): - if "." in value: - value = float(value) - elif value.isdigit(): - value = int(value) - _LOGGER.debug("Raw value for %s: %s", self._attr_name, value) - return value diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3e706be --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,85 @@ +[tool.black] +target-version = ["py310"] +exclude = 'generated' + +[tool.isort] +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +known_first_party = [ + "homeassistant", + "tests", +] +forced_separate = [ + "tests", +] +combine_as_imports = true + +[tool.pylint.MASTER] +jobs = 2 +persistent = false +extension-pkg-whitelist = [ + "ciso8601", + "cv2", +] + +[tool.pylint."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-method", + "consider-using-f-string", + "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", + "unspecified-encoding", + "unused-argument", + "wrong-import-order", +] +enable = [ + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", +] + +[tool.pylint.REPORTS] +score = false + +[tool.pylint.TYPECHECK] +ignored-classes = [ + "_CountingAttr", # for attrs +] + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF" + +[tool.pylint.EXCEPTIONS] +overgeneral-exceptions = [ + "BaseException", + "Exception", +] diff --git a/requirements.test.txt b/requirements.test.txt index 36c01d4..b671ac1 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,5 +1,5 @@ -flake8 +pylint pytest pytest-cov pytest-homeassistant-custom-component -subarulink==0.5.0 +subarulink==0.6.0 diff --git a/tests/api_responses.py b/tests/api_responses.py index a1f9f5f..065d98b 100644 --- a/tests/api_responses.py +++ b/tests/api_responses.py @@ -1,13 +1,6 @@ """Sample API response data for tests.""" from datetime import datetime, timezone -from subarulink.const import ( - BAD_AVG_FUEL_CONSUMPTION, - BAD_EXTERNAL_TEMP, - BAD_ODOMETER, - BAD_TIRE_PRESSURE, -) - from custom_components.subaru.const import ( API_GEN_1, API_GEN_2, @@ -57,6 +50,8 @@ }, } +MOCK_DATETIME = datetime.fromtimestamp(1595560000, timezone.utc) + VEHICLE_STATUS_EV = { VEHICLE_CLIMATE_SELECTED_PRESET: None, VEHICLE_CLIMATE: [ @@ -148,7 +143,7 @@ ], VEHICLE_STATUS: { "AVG_FUEL_CONSUMPTION": 2.3, - "BATTERY_VOLTAGE": "12.0", + "BATTERY_VOLTAGE": 12.0, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", "DOOR_BOOT_POSITION": "CLOSED", @@ -168,18 +163,11 @@ "EV_DISTANCE_TO_EMPTY": 1, "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "20", - "EV_TIME_TO_FULLY_CHARGED": "300", - "EV_TIME_TO_FULLY_CHARGED_UTC": datetime( - 2020, 7, 24, 8, 6, 40, tzinfo=timezone.utc - ), - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", - "EXT_EXTERNAL_TEMP": "21.5", + "EV_STATE_OF_CHARGE_PERCENT": 20, + "EV_TIME_TO_FULLY_CHARGED_UTC": MOCK_DATETIME, + "EXT_EXTERNAL_TEMP": 21.5, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": "150", + "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", "POSITION_TIMESTAMP": 1595560000.0, "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", @@ -202,7 +190,7 @@ "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": BAD_TIRE_PRESSURE, + "TYRE_PRESSURE_FRONT_LEFT": 0, "TYRE_PRESSURE_FRONT_RIGHT": 2550, "TYRE_PRESSURE_REAR_LEFT": 2450, "TYRE_PRESSURE_REAR_RIGHT": 2350, @@ -223,88 +211,11 @@ }, } -VEHICLE_STATUS_EV_INVALID_ITEMS = { - "status": { - "AVG_FUEL_CONSUMPTION": BAD_AVG_FUEL_CONSUMPTION, - "BATTERY_VOLTAGE": "12.0", - "DISTANCE_TO_EMPTY_FUEL": 707, - "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", - "DOOR_BOOT_POSITION": "CLOSED", - "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", - "DOOR_ENGINE_HOOD_POSITION": "CLOSED", - "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", - "DOOR_FRONT_LEFT_POSITION": "CLOSED", - "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", - "DOOR_FRONT_RIGHT_POSITION": "CLOSED", - "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", - "DOOR_REAR_LEFT_POSITION": "CLOSED", - "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", - "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EV_CHARGER_STATE_TYPE": "CHARGING", - "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", - "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": 1, - "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", - "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "20", - "EV_TIME_TO_FULLY_CHARGED": "300", - "EV_TIME_TO_FULLY_CHARGED_UTC": datetime( - 2020, 7, 24, 8, 6, 40, tzinfo=timezone.utc - ), - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", - "EXT_EXTERNAL_TEMP": BAD_EXTERNAL_TEMP, - "ODOMETER": BAD_ODOMETER, - "POSITION_HEADING_DEGREE": "150", - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, - "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", - "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", - "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", - "TIMESTAMP": 1595560000.0, - "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": BAD_TIRE_PRESSURE, - "TYRE_PRESSURE_FRONT_RIGHT": BAD_TIRE_PRESSURE, - "TYRE_PRESSURE_REAR_LEFT": BAD_TIRE_PRESSURE, - "TYRE_PRESSURE_REAR_RIGHT": BAD_TIRE_PRESSURE, - "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", - "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", - "TYRE_STATUS_REAR_LEFT": "UNKNOWN", - "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", - "VEHICLE_STATE_TYPE": "IGNITION_OFF", - "WINDOW_BACK_STATUS": "UNKNOWN", - "WINDOW_FRONT_LEFT_STATUS": "VENTED", - "WINDOW_FRONT_RIGHT_STATUS": "VENTED", - "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", - "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", - "WINDOW_SUNROOF_STATUS": "UNKNOWN", - "heading": 170, - "latitude": 40.0, - "longitude": -100.0, - } -} VEHICLE_STATUS_G2 = { "status": { "AVG_FUEL_CONSUMPTION": 2.3, - "BATTERY_VOLTAGE": "12.0", + "BATTERY_VOLTAGE": 12.0, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", "DOOR_BOOT_POSITION": "CLOSED", @@ -318,9 +229,9 @@ "DOOR_REAR_LEFT_POSITION": "CLOSED", "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EXT_EXTERNAL_TEMP": "21.5", + "EXT_EXTERNAL_TEMP": None, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": "150", + "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", "POSITION_TIMESTAMP": 1595560000.0, "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", @@ -353,7 +264,7 @@ "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", "VEHICLE_STATE_TYPE": "IGNITION_OFF", "WINDOW_BACK_STATUS": "UNKNOWN", - "WINDOW_FRONT_LEFT_STATUS": "OPEN", + "WINDOW_FRONT_LEFT_STATUS": "VENTED", "WINDOW_FRONT_RIGHT_STATUS": "VENTED", "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", @@ -375,12 +286,7 @@ "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", - "EV_TIME_TO_FULLY_CHARGED": "300", - "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T08:06:40+00:00", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "EXT_EXTERNAL_TEMP": "70.7", "ODOMETER": "766.8", "POSITION_HEADING_DEGREE": "150", @@ -388,7 +294,7 @@ "POSITION_TIMESTAMP": 1595560000.0, "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "unavailable", + "TYRE_PRESSURE_FRONT_LEFT": "0.0", "TYRE_PRESSURE_FRONT_RIGHT": "37.0", "TYRE_PRESSURE_REAR_LEFT": "35.5", "TYRE_PRESSURE_REAR_RIGHT": "34.1", @@ -409,12 +315,7 @@ "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", - "EV_TIME_TO_FULLY_CHARGED": "300", - "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T08:06:40+00:00", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "EXT_EXTERNAL_TEMP": "21.5", "ODOMETER": "1234", "POSITION_HEADING_DEGREE": "150", @@ -422,7 +323,7 @@ "POSITION_TIMESTAMP": 1595560000.0, "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "unavailable", + "TYRE_PRESSURE_FRONT_LEFT": "0", "TYRE_PRESSURE_FRONT_RIGHT": "2550", "TYRE_PRESSURE_REAR_LEFT": "2450", "TYRE_PRESSURE_REAR_RIGHT": "2350", @@ -466,14 +367,11 @@ "longitude": "unavailable", "DOOR_BOOT_POSITION": "unavailable", "DOOR_ENGINE_HOOD_POSITION": "unavailable", - "EV_IS_PLUGGED_IN": "unavailable", - "EV_CHARGER_STATE_TYPE": "unavailable", "WINDOW_FRONT_LEFT_STATUS": "unavailable", "WINDOW_FRONT_RIGHT_STATUS": "unavailable", "WINDOW_REAR_LEFT_STATUS": "unavailable", "WINDOW_REAR_RIGHT_STATUS": "unavailable", "WINDOW_SUNROOF_STATUS": "unavailable", - "VEHICLE_STATE_TYPE": "unavailable", "DOOR_FRONT_LEFT_POSITION": "unavailable", "DOOR_FRONT_RIGHT_POSITION": "unavailable", "DOOR_REAR_LEFT_POSITION": "unavailable", diff --git a/tests/bandit.yaml b/tests/bandit.yaml index ebd284e..3254327 100644 --- a/tests/bandit.yaml +++ b/tests/bandit.yaml @@ -1,17 +1,399 @@ -# https://bandit.readthedocs.io/en/latest/config.html +### This config may optionally select a subset of tests to run or skip by +### filling out the 'tests' and 'skips' lists given below. If no tests are +### specified for inclusion then it is assumed all tests are desired. The skips +### set will remove specific tests from the include set. This can be controlled +### using the -t/-s CLI options. Note that the same test ID should not appear +### in both 'tests' and 'skips', this would be nonsensical and is detected by +### Bandit at runtime. + +# Available tests: +# B101 : assert_used +# B102 : exec_used +# B103 : set_bad_file_permissions +# B104 : hardcoded_bind_all_interfaces +# B105 : hardcoded_password_string +# B106 : hardcoded_password_funcarg +# B107 : hardcoded_password_default +# B108 : hardcoded_tmp_directory +# B110 : try_except_pass +# B112 : try_except_continue +# B201 : flask_debug_true +# B301 : pickle +# B302 : marshal +# B303 : md5 +# B304 : ciphers +# B305 : cipher_modes +# B306 : mktemp_q +# B307 : eval +# B308 : mark_safe +# B309 : httpsconnection +# B310 : urllib_urlopen +# B311 : random +# B312 : telnetlib +# B313 : xml_bad_cElementTree +# B314 : xml_bad_ElementTree +# B315 : xml_bad_expatreader +# B316 : xml_bad_expatbuilder +# B317 : xml_bad_sax +# B318 : xml_bad_minidom +# B319 : xml_bad_pulldom +# B320 : xml_bad_etree +# B321 : ftplib +# B323 : unverified_context +# B324 : hashlib_insecure_functions +# B325 : tempnam +# B401 : import_telnetlib +# B402 : import_ftplib +# B403 : import_pickle +# B404 : import_subprocess +# B405 : import_xml_etree +# B406 : import_xml_sax +# B407 : import_xml_expat +# B408 : import_xml_minidom +# B409 : import_xml_pulldom +# B410 : import_lxml +# B411 : import_xmlrpclib +# B412 : import_httpoxy +# B413 : import_pycrypto +# B415 : import_pyghmi +# B501 : request_with_no_cert_validation +# B502 : ssl_with_bad_version +# B503 : ssl_with_bad_defaults +# B504 : ssl_with_no_version +# B505 : weak_cryptographic_key +# B506 : yaml_load +# B507 : ssh_no_host_key_verification +# B508 : snmp_insecure_version +# B509 : snmp_weak_cryptography +# B601 : paramiko_calls +# B602 : subprocess_popen_with_shell_equals_true +# B603 : subprocess_without_shell_equals_true +# B604 : any_other_function_with_shell_equals_true +# B605 : start_process_with_a_shell +# B606 : start_process_with_no_shell +# B607 : start_process_with_partial_path +# B608 : hardcoded_sql_expressions +# B609 : linux_commands_wildcard_injection +# B610 : django_extra_used +# B611 : django_rawsql_used +# B701 : jinja2_autoescape_false +# B702 : use_of_mako_templates +# B703 : django_mark_safe + +# (optional) list included test IDs here, eg '[B101, B406]': tests: - - B108 - - B306 - - B307 - - B313 - - B314 - - B315 - - B316 - - B317 - - B318 - - B319 - - B320 - - B325 - - B602 - - B604 + +# (optional) list skipped test IDs here, eg '[B101, B406]': +skips: + - B101 + +### (optional) plugin settings - some test plugins require configuration data +### that may be given here, per-plugin. All bandit test plugins have a built in +### set of sensible defaults and these will be used if no configuration is +### provided. It is not necessary to provide settings for every (or any) plugin +### if the defaults are acceptable. + +any_other_function_with_shell_equals_true: + no_shell: + - os.execl + - os.execle + - os.execlp + - os.execlpe + - os.execv + - os.execve + - os.execvp + - os.execvpe + - os.spawnl + - os.spawnle + - os.spawnlp + - os.spawnlpe + - os.spawnv + - os.spawnve + - os.spawnvp + - os.spawnvpe + - os.startfile + shell: + - os.system + - os.popen + - os.popen2 + - os.popen3 + - os.popen4 + - popen2.popen2 + - popen2.popen3 + - popen2.popen4 + - popen2.Popen3 + - popen2.Popen4 + - commands.getoutput + - commands.getstatusoutput + subprocess: + - subprocess.Popen + - subprocess.call + - subprocess.check_call + - subprocess.check_output + - subprocess.run +assert_used: + skips: [] +hardcoded_tmp_directory: + tmp_dirs: + - /tmp + - /var/tmp + - /dev/shm +linux_commands_wildcard_injection: + no_shell: + - os.execl + - os.execle + - os.execlp + - os.execlpe + - os.execv + - os.execve + - os.execvp + - os.execvpe + - os.spawnl + - os.spawnle + - os.spawnlp + - os.spawnlpe + - os.spawnv + - os.spawnve + - os.spawnvp + - os.spawnvpe + - os.startfile + shell: + - os.system + - os.popen + - os.popen2 + - os.popen3 + - os.popen4 + - popen2.popen2 + - popen2.popen3 + - popen2.popen4 + - popen2.Popen3 + - popen2.Popen4 + - commands.getoutput + - commands.getstatusoutput + subprocess: + - subprocess.Popen + - subprocess.call + - subprocess.check_call + - subprocess.check_output + - subprocess.run +ssl_with_bad_defaults: + bad_protocol_versions: + - PROTOCOL_SSLv2 + - SSLv2_METHOD + - SSLv23_METHOD + - PROTOCOL_SSLv3 + - PROTOCOL_TLSv1 + - SSLv3_METHOD + - TLSv1_METHOD +ssl_with_bad_version: + bad_protocol_versions: + - PROTOCOL_SSLv2 + - SSLv2_METHOD + - SSLv23_METHOD + - PROTOCOL_SSLv3 + - PROTOCOL_TLSv1 + - SSLv3_METHOD + - TLSv1_METHOD +start_process_with_a_shell: + no_shell: + - os.execl + - os.execle + - os.execlp + - os.execlpe + - os.execv + - os.execve + - os.execvp + - os.execvpe + - os.spawnl + - os.spawnle + - os.spawnlp + - os.spawnlpe + - os.spawnv + - os.spawnve + - os.spawnvp + - os.spawnvpe + - os.startfile + shell: + - os.system + - os.popen + - os.popen2 + - os.popen3 + - os.popen4 + - popen2.popen2 + - popen2.popen3 + - popen2.popen4 + - popen2.Popen3 + - popen2.Popen4 + - commands.getoutput + - commands.getstatusoutput + subprocess: + - subprocess.Popen + - subprocess.call + - subprocess.check_call + - subprocess.check_output + - subprocess.run +start_process_with_no_shell: + no_shell: + - os.execl + - os.execle + - os.execlp + - os.execlpe + - os.execv + - os.execve + - os.execvp + - os.execvpe + - os.spawnl + - os.spawnle + - os.spawnlp + - os.spawnlpe + - os.spawnv + - os.spawnve + - os.spawnvp + - os.spawnvpe + - os.startfile + shell: + - os.system + - os.popen + - os.popen2 + - os.popen3 + - os.popen4 + - popen2.popen2 + - popen2.popen3 + - popen2.popen4 + - popen2.Popen3 + - popen2.Popen4 + - commands.getoutput + - commands.getstatusoutput + subprocess: + - subprocess.Popen + - subprocess.call + - subprocess.check_call + - subprocess.check_output + - subprocess.run +start_process_with_partial_path: + no_shell: + - os.execl + - os.execle + - os.execlp + - os.execlpe + - os.execv + - os.execve + - os.execvp + - os.execvpe + - os.spawnl + - os.spawnle + - os.spawnlp + - os.spawnlpe + - os.spawnv + - os.spawnve + - os.spawnvp + - os.spawnvpe + - os.startfile + shell: + - os.system + - os.popen + - os.popen2 + - os.popen3 + - os.popen4 + - popen2.popen2 + - popen2.popen3 + - popen2.popen4 + - popen2.Popen3 + - popen2.Popen4 + - commands.getoutput + - commands.getstatusoutput + subprocess: + - subprocess.Popen + - subprocess.call + - subprocess.check_call + - subprocess.check_output + - subprocess.run +subprocess_popen_with_shell_equals_true: + no_shell: + - os.execl + - os.execle + - os.execlp + - os.execlpe + - os.execv + - os.execve + - os.execvp + - os.execvpe + - os.spawnl + - os.spawnle + - os.spawnlp + - os.spawnlpe + - os.spawnv + - os.spawnve + - os.spawnvp + - os.spawnvpe + - os.startfile + shell: + - os.system + - os.popen + - os.popen2 + - os.popen3 + - os.popen4 + - popen2.popen2 + - popen2.popen3 + - popen2.popen4 + - popen2.Popen3 + - popen2.Popen4 + - commands.getoutput + - commands.getstatusoutput + subprocess: + - subprocess.Popen + - subprocess.call + - subprocess.check_call + - subprocess.check_output + - subprocess.run +subprocess_without_shell_equals_true: + no_shell: + - os.execl + - os.execle + - os.execlp + - os.execlpe + - os.execv + - os.execve + - os.execvp + - os.execvpe + - os.spawnl + - os.spawnle + - os.spawnlp + - os.spawnlpe + - os.spawnv + - os.spawnve + - os.spawnvp + - os.spawnvpe + - os.startfile + shell: + - os.system + - os.popen + - os.popen2 + - os.popen3 + - os.popen4 + - popen2.popen2 + - popen2.popen3 + - popen2.popen4 + - popen2.Popen3 + - popen2.Popen4 + - commands.getoutput + - commands.getstatusoutput + subprocess: + - subprocess.Popen + - subprocess.call + - subprocess.check_call + - subprocess.check_output + - subprocess.run +try_except_continue: + check_typed_exception: false +try_except_pass: + check_typed_exception: false +weak_cryptographic_key: + weak_key_size_dsa_high: 1024 + weak_key_size_dsa_medium: 2048 + weak_key_size_ec_high: 160 + weak_key_size_ec_medium: 224 + weak_key_size_rsa_high: 1024 + weak_key_size_rsa_medium: 2048 + diff --git a/tests/conftest.py b/tests/conftest.py index ed70146..5d7755b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,18 +13,16 @@ from custom_components.subaru.const import ( CONF_COUNTRY, CONF_NOTIFICATION_OPTION, - CONF_UPDATE_ENABLED, + CONF_POLLING_OPTION, DOMAIN, - FETCH_INTERVAL, - UPDATE_INTERVAL, VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, VEHICLE_NAME, - NotificationOptions, ) +from custom_components.subaru.options import NotificationOptions, PollingOptions from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME @@ -57,7 +55,7 @@ MOCK_API_LIGHTS = f"{MOCK_API}lights" TEST_USERNAME = "user@email.com" -TEST_PASSWORD = "password" +TEST_PASSWORD = "password" # nosec TEST_PIN = "1234" TEST_DEVICE_ID = 1613183362 TEST_COUNTRY = COUNTRY_USA @@ -76,23 +74,13 @@ CONF_DEVICE_ID: TEST_DEVICE_ID, } -TEST_OPTIONS = { - CONF_UPDATE_ENABLED: True, - CONF_NOTIFICATION_OPTION: NotificationOptions.SUCCESS.value, -} - -TEST_ENTITY_ID = "sensor.test_vehicle_2_odometer" - - -def advance_time_to_next_fetch(hass): - """Fast forward to next fetch.""" - future = dt_util.utcnow() + timedelta(seconds=FETCH_INTERVAL + 30) - async_fire_time_changed(hass, future) +TEST_DEVICE_NAME = "test_vehicle_2" +TEST_ENTITY_ID = f"sensor.{TEST_DEVICE_NAME}_odometer" -def advance_time_to_next_update(hass): - """Fast forward to next update.""" - future = dt_util.utcnow() + timedelta(seconds=UPDATE_INTERVAL + 30) +def advance_time(hass, seconds): + """Fast forward time.""" + future = dt_util.utcnow() + timedelta(seconds=seconds + 30) async_fire_time_changed(hass, future) @@ -104,18 +92,30 @@ async def setup_subaru_integration( connect_effect=None, fetch_effect=None, saved_cache=None, + charge_polling=None, ): """Create Subaru entry.""" if saved_cache: mock_restore_cache( - hass, (State("select.test_vehicle_2_climate_preset", "Full Heat"),), + hass, + (State("select.test_vehicle_2_climate_preset", "Full Heat"),), ) + test_options = { + CONF_POLLING_OPTION: PollingOptions.CHARGING.value + if charge_polling + else PollingOptions.ENABLE.value, + CONF_NOTIFICATION_OPTION: NotificationOptions.SUCCESS.value, + } + assert await async_setup_component(hass, HA_DOMAIN, {}) assert await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry( - domain=DOMAIN, data=TEST_CONFIG, options=TEST_OPTIONS, entry_id=1, + domain=DOMAIN, + data=TEST_CONFIG, + options=test_options, + entry_id=1, ) config_entry.add_to_hass(hass) @@ -124,13 +124,17 @@ async def setup_subaru_integration( return_value=connect_effect is None, side_effect=connect_effect, ), patch(MOCK_API_GET_VEHICLES, return_value=vehicle_list,), patch( - MOCK_API_VIN_TO_NAME, return_value=vehicle_data[VEHICLE_NAME], + MOCK_API_VIN_TO_NAME, + return_value=vehicle_data[VEHICLE_NAME], ), patch( - MOCK_API_GET_API_GEN, return_value=vehicle_data[VEHICLE_API_GEN], + MOCK_API_GET_API_GEN, + return_value=vehicle_data[VEHICLE_API_GEN], ), patch( - MOCK_API_GET_EV_STATUS, return_value=vehicle_data[VEHICLE_HAS_EV], + MOCK_API_GET_EV_STATUS, + return_value=vehicle_data[VEHICLE_HAS_EV], ), patch( - MOCK_API_GET_RES_STATUS, return_value=vehicle_data[VEHICLE_HAS_REMOTE_START], + MOCK_API_GET_RES_STATUS, + return_value=vehicle_data[VEHICLE_HAS_REMOTE_START], ), patch( MOCK_API_GET_REMOTE_STATUS, return_value=vehicle_data[VEHICLE_HAS_REMOTE_SERVICE], @@ -138,7 +142,8 @@ async def setup_subaru_integration( MOCK_API_GET_SAFETY_STATUS, return_value=vehicle_data[VEHICLE_HAS_SAFETY_SERVICE], ), patch( - MOCK_API_GET_DATA, return_value=vehicle_status, + MOCK_API_GET_DATA, + return_value=vehicle_status, ), patch( MOCK_API_UPDATE, ), patch( @@ -181,3 +186,20 @@ async def ev_entry_with_saved_climate(hass, enable_custom_integrations): assert hass.config_entries.async_get_entry(entry.entry_id) assert entry.state is ConfigEntryState.LOADED return entry + + +@pytest.fixture +async def ev_entry_charge_polling(hass, enable_custom_integrations): + """Create a Subaru EV entity but with charge polling option.""" + entry = await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + charge_polling=True, + ) + assert DOMAIN in hass.config_entries.async_domains() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_get_entry(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + return entry diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 08e3528..f813201 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -5,7 +5,7 @@ from subarulink.const import DOOR_ENGINE_HOOD_POSITION, VEHICLE_STATUS from custom_components.subaru.binary_sensor import API_GEN_2_SENSORS, EV_SENSORS -from custom_components.subaru.const import VEHICLE_NAME +from custom_components.subaru.const import FETCH_INTERVAL, VEHICLE_NAME from homeassistant.const import STATE_UNAVAILABLE from homeassistant.util import slugify @@ -17,7 +17,7 @@ VEHICLE_STATUS_EV, ) -from tests.conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time_to_next_fetch +from tests.conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time VEHICLE_NAME = VEHICLE_DATA[TEST_VIN_2_EV][VEHICLE_NAME] @@ -30,7 +30,7 @@ async def test_binary_sensors_ev(hass, ev_entry): async def test_binary_sensors_missing_vin_data(hass, ev_entry): """Test for missing VIN dataset.""" with patch(MOCK_API_FETCH), patch(MOCK_API_GET_DATA, return_value=None): - advance_time_to_next_fetch(hass) + advance_time(hass, FETCH_INTERVAL) await hass.async_block_till_done() _assert_data(hass, EXPECTED_STATE_EV_UNAVAILABLE) @@ -39,7 +39,7 @@ async def test_binary_sensors_missing_vin_data(hass, ev_entry): async def test_binary_sensors_missing_field(hass, ev_entry): """Test for missing field.""" with patch(MOCK_API_FETCH), patch(MOCK_API_GET_DATA, return_value=None): - advance_time_to_next_fetch(hass) + advance_time(hass, FETCH_INTERVAL) await hass.async_block_till_done() missing_field_set = deepcopy(VEHICLE_STATUS_EV) missing_field_set[VEHICLE_STATUS].pop(DOOR_ENGINE_HOOD_POSITION) @@ -47,7 +47,7 @@ async def test_binary_sensors_missing_field(hass, ev_entry): with patch(MOCK_API_FETCH), patch( MOCK_API_GET_DATA, return_value=missing_field_set ): - advance_time_to_next_fetch(hass) + advance_time(hass, FETCH_INTERVAL) await hass.async_block_till_done() expected_state_missing_field = deepcopy(EXPECTED_STATE_EV_BINARY_SENSORS) expected_state_missing_field[DOOR_ENGINE_HOOD_POSITION] = STATE_UNAVAILABLE @@ -63,7 +63,7 @@ def _assert_data(hass, expected_state): f"binary_sensor.{slugify(f'{VEHICLE_NAME} {item.suffix}')}" ] = expected_state[item.key] - for sensor in expected_states: + for sensor, state in expected_states.items(): actual = hass.states.get(sensor) if actual: - assert actual.state == expected_states[sensor] + assert actual.state == state diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 6f85893..a2660fd 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -10,10 +10,10 @@ from custom_components.subaru import config_flow from custom_components.subaru.const import ( CONF_NOTIFICATION_OPTION, - CONF_UPDATE_ENABLED, + CONF_POLLING_OPTION, DOMAIN, - NotificationOptions, ) +from custom_components.subaru.options import NotificationOptions, PollingOptions from homeassistant import config_entries from homeassistant.const import CONF_DEVICE_ID, CONF_PIN from homeassistant.setup import async_setup_component @@ -53,9 +53,13 @@ async def test_user_form_repeat_identifier(hass, user_form): ) entry.add_to_hass(hass) - with patch(MOCK_API_CONNECT, return_value=True,) as mock_connect: + with patch( + MOCK_API_CONNECT, + return_value=True, + ) as mock_connect: result = await hass.config_entries.flow.async_configure( - user_form["flow_id"], TEST_CREDS, + user_form["flow_id"], + TEST_CREDS, ) assert len(mock_connect.mock_calls) == 0 assert result["type"] == "abort" @@ -64,9 +68,13 @@ async def test_user_form_repeat_identifier(hass, user_form): async def test_user_form_cannot_connect(hass, user_form): """Test we handle cannot connect error.""" - with patch(MOCK_API_CONNECT, side_effect=SubaruException(None),) as mock_connect: + with patch( + MOCK_API_CONNECT, + side_effect=SubaruException(None), + ) as mock_connect: result = await hass.config_entries.flow.async_configure( - user_form["flow_id"], TEST_CREDS, + user_form["flow_id"], + TEST_CREDS, ) assert len(mock_connect.mock_calls) == 1 assert result["type"] == "abort" @@ -76,10 +84,12 @@ async def test_user_form_cannot_connect(hass, user_form): async def test_user_form_invalid_auth(hass, user_form): """Test we handle invalid auth.""" with patch( - MOCK_API_CONNECT, side_effect=InvalidCredentials("invalidAccount"), + MOCK_API_CONNECT, + side_effect=InvalidCredentials("invalidAccount"), ) as mock_connect: result = await hass.config_entries.flow.async_configure( - user_form["flow_id"], TEST_CREDS, + user_form["flow_id"], + TEST_CREDS, ) assert len(mock_connect.mock_calls) == 1 assert result["type"] == "form" @@ -111,7 +121,8 @@ async def test_registered_pin_required(hass, user_form): async def test_two_factor_request_success(hass, two_factor_start_form): """Test two factor contact method selection.""" with patch( - MOCK_API_2FA_REQUEST, return_value=True, + MOCK_API_2FA_REQUEST, + return_value=True, ) as mock_two_factor_request, patch( MOCK_API_2FA_CONTACTS, new_callable=PropertyMock ) as mock_contacts: @@ -129,7 +140,8 @@ async def test_two_factor_request_success(hass, two_factor_start_form): async def test_two_factor_request_fail(hass, two_factor_start_form): """Test two factor contact method selection.""" with patch( - MOCK_API_2FA_REQUEST, return_value=False, + MOCK_API_2FA_REQUEST, + return_value=False, ) as mock_two_factor_request, patch( MOCK_API_2FA_CONTACTS, new_callable=PropertyMock ) as mock_contacts, pytest.raises( @@ -149,7 +161,8 @@ async def test_two_factor_request_fail(hass, two_factor_start_form): async def test_two_factor_verify_success(hass, two_factor_verify_form): """Test two factor verification.""" with patch( - MOCK_API_2FA_VERIFY, return_value=True, + MOCK_API_2FA_VERIFY, + return_value=True, ) as mock_two_factor_verify, patch( MOCK_API_IS_PIN_REQUIRED, return_value=True ) as mock_is_in_required: @@ -164,7 +177,8 @@ async def test_two_factor_verify_success(hass, two_factor_verify_form): async def test_two_factor_verify_bad_format(hass, two_factor_verify_form): """Test two factor verification bad format.""" with patch( - MOCK_API_2FA_VERIFY, return_value=False, + MOCK_API_2FA_VERIFY, + return_value=False, ) as mock_two_factor_verify, patch( MOCK_API_IS_PIN_REQUIRED, return_value=True ) as mock_is_pin_required: @@ -180,7 +194,8 @@ async def test_two_factor_verify_bad_format(hass, two_factor_verify_form): async def test_two_factor_verify_fail(hass, two_factor_verify_form): """Test two factor verification failure.""" with patch( - MOCK_API_2FA_VERIFY, return_value=False, + MOCK_API_2FA_VERIFY, + return_value=False, ) as mock_two_factor_verify, patch( MOCK_API_IS_PIN_REQUIRED, return_value=True ) as mock_is_pin_required: @@ -196,9 +211,11 @@ async def test_two_factor_verify_fail(hass, two_factor_verify_form): async def test_user_form_pin_not_required(hass, two_factor_verify_form): """Test successful login when no PIN is required.""" with patch( - MOCK_API_2FA_VERIFY, return_value=True, + MOCK_API_2FA_VERIFY, + return_value=True, ) as mock_two_factor_verify, patch( - MOCK_API_IS_PIN_REQUIRED, return_value=False, + MOCK_API_IS_PIN_REQUIRED, + return_value=False, ) as mock_is_pin_required, patch( ASYNC_SETUP_ENTRY, return_value=True ) as mock_setup_entry: @@ -221,7 +238,7 @@ async def test_user_form_pin_not_required(hass, two_factor_verify_form): "version": 1, "data": deepcopy(TEST_CONFIG), "options": {}, - 'context': {'source': 'user'}, + "context": {"source": "user"}, } expected["data"][CONF_PIN] = None result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID @@ -231,7 +248,8 @@ async def test_user_form_pin_not_required(hass, two_factor_verify_form): async def test_pin_form_success(hass, pin_form): """Test successful PIN entry.""" with patch(MOCK_API_TEST_PIN, return_value=True,) as mock_test_pin, patch( - MOCK_API_UPDATE_SAVED_PIN, return_value=True, + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, ) as mock_update_saved_pin, patch( ASYNC_SETUP_ENTRY, return_value=True ) as mock_setup_entry: @@ -253,7 +271,7 @@ async def test_pin_form_success(hass, pin_form): "version": 1, "data": TEST_CONFIG, "options": {}, - 'context': {'source': 'user'}, + "context": {"source": "user"}, } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected @@ -262,9 +280,11 @@ async def test_pin_form_success(hass, pin_form): async def test_pin_form_incorrect_pin(hass, pin_form): """Test we handle invalid pin.""" with patch( - MOCK_API_TEST_PIN, side_effect=InvalidPIN("invalidPin"), + MOCK_API_TEST_PIN, + side_effect=InvalidPIN("invalidPin"), ) as mock_test_pin, patch( - MOCK_API_UPDATE_SAVED_PIN, return_value=True, + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, ) as mock_update_saved_pin: result = await hass.config_entries.flow.async_configure( pin_form["flow_id"], user_input={CONF_PIN: TEST_PIN} @@ -281,26 +301,26 @@ async def test_option_flow(hass, options_form): options_form["flow_id"], user_input={ CONF_NOTIFICATION_OPTION: NotificationOptions.PENDING.value, - CONF_UPDATE_ENABLED: False, + CONF_POLLING_OPTION: PollingOptions.DISABLE.value, }, ) assert result["type"] == "create_entry" assert result["data"] == { CONF_NOTIFICATION_OPTION: NotificationOptions.PENDING.value, - CONF_UPDATE_ENABLED: False, + CONF_POLLING_OPTION: PollingOptions.DISABLE.value, } -@pytest.fixture -async def user_form(hass, enable_custom_integrations): +@pytest.fixture(name="user_form") +async def fixture_user_form(hass, enable_custom_integrations): """Return initial form for Subaru config flow.""" return await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) -@pytest.fixture -async def two_factor_start_form(hass, user_form): +@pytest.fixture(name="two_factor_start_form") +async def fixture_two_factor_start_form(hass, user_form): """Return two factor form for Subaru config flow.""" with patch(MOCK_API_CONNECT, return_value=True), patch( MOCK_API_2FA_CONTACTS, new_callable=PropertyMock @@ -314,12 +334,13 @@ async def two_factor_start_form(hass, user_form): ) -@pytest.fixture -async def two_factor_verify_form(hass, two_factor_start_form): +@pytest.fixture(name="two_factor_verify_form") +async def fixture_two_factor_verify_form(hass, two_factor_start_form): """Return two factor form for Subaru config flow.""" - with patch(MOCK_API_2FA_REQUEST, return_value=True,), patch( - MOCK_API_2FA_CONTACTS, new_callable=PropertyMock - ) as mock_contacts: + with patch( + MOCK_API_2FA_REQUEST, + return_value=True, + ), patch(MOCK_API_2FA_CONTACTS, new_callable=PropertyMock) as mock_contacts: mock_contacts.return_value = { "phone": "123-123-1234", "userName": "email@addr.com", @@ -330,20 +351,21 @@ async def two_factor_verify_form(hass, two_factor_start_form): ) -@pytest.fixture -async def pin_form(hass, two_factor_verify_form): +@pytest.fixture(name="pin_form") +async def fixture_pin_form(hass, two_factor_verify_form): """Return PIN input form for Subaru config flow.""" - with patch(MOCK_API_2FA_VERIFY, return_value=True,), patch( - MOCK_API_IS_PIN_REQUIRED, return_value=True - ): + with patch( + MOCK_API_2FA_VERIFY, + return_value=True, + ), patch(MOCK_API_IS_PIN_REQUIRED, return_value=True): return await hass.config_entries.flow.async_configure( two_factor_verify_form["flow_id"], user_input={config_flow.CONF_VALIDATION_CODE: "123456"}, ) -@pytest.fixture -async def options_form(hass, enable_custom_integrations): +@pytest.fixture(name="options_form") +async def fixture_options_form(hass, enable_custom_integrations): """Return options form for Subaru config flow.""" entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) entry.add_to_hass(hass) diff --git a/tests/test_init.py b/tests/test_init.py index babc983..12584c3 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,13 +3,13 @@ from subarulink import InvalidCredentials, SubaruException -from custom_components.subaru.const import DOMAIN +from custom_components.subaru.const import DOMAIN, UPDATE_INTERVAL_CHARGING from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component from .api_responses import ( @@ -22,8 +22,10 @@ ) from .conftest import ( MOCK_API_FETCH, + MOCK_API_GET_DATA, MOCK_API_UPDATE, TEST_ENTITY_ID, + advance_time, setup_subaru_integration, ) @@ -110,21 +112,6 @@ async def test_update_skip_unsubscribed(hass, enable_custom_integrations): mock_fetch.assert_not_called() -async def test_update_disabled(hass, ev_entry): - """Test update function disable option.""" - with patch(MOCK_API_FETCH, side_effect=SubaruException("403 Error"),), patch( - MOCK_API_UPDATE, - ) as mock_update: - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, - blocking=True, - ) - await hass.async_block_till_done() - mock_update.assert_not_called() - - async def test_fetch_failed(hass, enable_custom_integrations): """Tests when fetch fails.""" await setup_subaru_integration( @@ -145,3 +132,32 @@ async def test_unload_entry(hass, ev_entry): assert await hass.config_entries.async_unload(ev_entry.entry_id) await hass.async_block_till_done() assert ev_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_charging_polling(hass, ev_entry_charge_polling): + """Test charging polling option.""" + hass.states.async_set( + "binary_sensor.test_vehicle_2_ev_battery_charging", STATE_OFF, force_update=True + ) + + with patch(MOCK_API_UPDATE, return_value=True) as mock_update, patch( + MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV + ) as mock_get_data: + # Charging state is off, so update shouldn't happen, but state will be updated by get_data + assert ( + hass.states.get("binary_sensor.test_vehicle_2_ev_battery_charging").state + == STATE_OFF + ) + advance_time(hass, UPDATE_INTERVAL_CHARGING) + await hass.async_block_till_done() + mock_update.assert_not_called() + mock_get_data.assert_called_once() + assert ( + hass.states.get("binary_sensor.test_vehicle_2_ev_battery_charging").state + == STATE_ON + ) + + # Charging state is now on, so update should hppen + advance_time(hass, UPDATE_INTERVAL_CHARGING) + await hass.async_block_till_done() + mock_update.assert_called_once() diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 20506e5..b526984 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,12 +1,11 @@ """Test Subaru sensors.""" from unittest.mock import patch -from custom_components.subaru.const import VEHICLE_NAME +from custom_components.subaru.const import FETCH_INTERVAL from custom_components.subaru.sensor import ( API_GEN_2_SENSORS, EV_SENSORS, SAFETY_SENSORS, - SENSOR_KEY_TO_SUFFIX, ) from homeassistant.util import slugify from homeassistant.util.unit_system import IMPERIAL_SYSTEM @@ -15,14 +14,9 @@ EXPECTED_STATE_EV_IMPERIAL, EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, - TEST_VIN_2_EV, - VEHICLE_DATA, VEHICLE_STATUS_EV, ) - -from tests.conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time_to_next_fetch - -VEHICLE_NAME = VEHICLE_DATA[TEST_VIN_2_EV][VEHICLE_NAME] +from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, TEST_DEVICE_NAME, advance_time async def test_sensors_ev_imperial(hass, ev_entry): @@ -32,7 +26,7 @@ async def test_sensors_ev_imperial(hass, ev_entry): with patch(MOCK_API_FETCH), patch( MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV ): - advance_time_to_next_fetch(hass) + advance_time(hass, FETCH_INTERVAL) await hass.async_block_till_done() _assert_data(hass, EXPECTED_STATE_EV_IMPERIAL) @@ -46,7 +40,7 @@ async def test_sensors_ev_metric(hass, ev_entry): async def test_sensors_missing_vin_data(hass, ev_entry): """Test for missing VIN dataset.""" with patch(MOCK_API_FETCH), patch(MOCK_API_GET_DATA, return_value=None): - advance_time_to_next_fetch(hass) + advance_time(hass, FETCH_INTERVAL) await hass.async_block_till_done() _assert_data(hass, EXPECTED_STATE_EV_UNAVAILABLE) @@ -59,10 +53,9 @@ def _assert_data(hass, expected_state): expected_states = {} for item in sensor_list: expected_states[ - f"sensor.{slugify(f'{VEHICLE_NAME} {SENSOR_KEY_TO_SUFFIX[item.key]}')}" + f"sensor.{slugify(f'{TEST_DEVICE_NAME} {item.name}')}" ] = expected_state[item.key] - for sensor in expected_states: + for sensor, value in expected_states.items(): actual = hass.states.get(sensor) - assert actual - assert actual.state == expected_states[sensor] + assert actual.state == value diff --git a/tests/test_services.py b/tests/test_services.py index 563b68d..6ed456f 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -64,7 +64,10 @@ async def test_remote_service_horn(hass, ev_entry): """Test remote service horn.""" with patch(MOCK_API_HORN) as mock_horn: await hass.services.async_call( - DOMAIN, REMOTE_SERVICE_HORN, {VEHICLE_VIN: TEST_VIN_2_EV}, blocking=True, + DOMAIN, + REMOTE_SERVICE_HORN, + {VEHICLE_VIN: TEST_VIN_2_EV}, + blocking=True, ) await hass.async_block_till_done() mock_horn.assert_called_once() @@ -76,7 +79,10 @@ async def test_remote_service_fetch(hass, ev_entry): MOCK_API_FETCH ) as mock_fetch: await hass.services.async_call( - DOMAIN, REMOTE_SERVICE_FETCH, {VEHICLE_VIN: TEST_VIN_2_EV}, blocking=True, + DOMAIN, + REMOTE_SERVICE_FETCH, + {VEHICLE_VIN: TEST_VIN_2_EV}, + blocking=True, ) await hass.async_block_till_done() mock_fetch.assert_called_once() @@ -88,7 +94,10 @@ async def test_remote_service_update(hass, ev_entry): MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV ), patch(MOCK_API_UPDATE, return_value=True) as mock_update: await hass.services.async_call( - DOMAIN, REMOTE_SERVICE_UPDATE, {VEHICLE_VIN: TEST_VIN_2_EV}, blocking=True, + DOMAIN, + REMOTE_SERVICE_UPDATE, + {VEHICLE_VIN: TEST_VIN_2_EV}, + blocking=True, ) await hass.async_block_till_done() mock_update.assert_called_once() @@ -110,7 +119,10 @@ async def test_remote_service_invalid_vin(hass, ev_entry): async def test_remote_service_invalid_pin(hass, ev_entry): """Test remote service request with invalid PIN.""" - with patch(MOCK_API_HORN, side_effect=InvalidPIN("invalid PIN"),) as mock_horn: + with patch( + MOCK_API_HORN, + side_effect=InvalidPIN("invalid PIN"), + ) as mock_horn: with raises(HomeAssistantError): await hass.services.async_call( DOMAIN,