├── custom_components └── connectedcars_io │ ├── minvw │ ├── __main__.py │ ├── __init__.py │ └── minvw.py │ ├── const.py │ ├── manifest.json │ ├── strings.json │ ├── translations │ └── en.json │ ├── __init__.py │ ├── config_flow.py │ ├── device_tracker.py │ ├── binary_sensor.py │ └── sensor.py ├── images ├── config.png ├── device.png ├── options.png ├── dashboard.png └── location_state.png ├── hacs.json ├── .github └── workflows │ ├── hassfest.yml │ └── validate.yml ├── .gitignore └── README.md /custom_components/connectedcars_io/minvw/__main__.py: -------------------------------------------------------------------------------- 1 | """Wrapper for connectedcars.io.""" 2 | -------------------------------------------------------------------------------- /images/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnxxx/homeassistant-connectedcars_io/HEAD/images/config.png -------------------------------------------------------------------------------- /images/device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnxxx/homeassistant-connectedcars_io/HEAD/images/device.png -------------------------------------------------------------------------------- /images/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnxxx/homeassistant-connectedcars_io/HEAD/images/options.png -------------------------------------------------------------------------------- /images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnxxx/homeassistant-connectedcars_io/HEAD/images/dashboard.png -------------------------------------------------------------------------------- /images/location_state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnxxx/homeassistant-connectedcars_io/HEAD/images/location_state.png -------------------------------------------------------------------------------- /custom_components/connectedcars_io/minvw/__init__.py: -------------------------------------------------------------------------------- 1 | """Wrapper for connectedcars.io.""" 2 | 3 | from .minvw import MinVW 4 | 5 | __version__ = '0.1.0' 6 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Connectedcars.io (Min Volkswagen)", 3 | "render_readme": true, 4 | "country": ["DK"], 5 | "zip_release": true, 6 | "filename": "connectedcars_io.zip" 7 | } 8 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/const.py: -------------------------------------------------------------------------------- 1 | """Support for connectedcars.io / Min Volkswagen integration.""" 2 | 3 | DOMAIN = "connectedcars_io" 4 | CONF_HEALTH_SENSITIVITY = "health_sensitivity" 5 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | ignore: brands 20 | 21 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "connectedcars_io", 3 | "name": "Connectedcars.io (Min Volkswagen)", 4 | "codeowners": [ 5 | "@jnxxx" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/jnxxx/homeassistant-connectedcars_io/", 10 | "homekit": {}, 11 | "integration_type": "hub", 12 | "iot_class": "cloud_polling", 13 | "issue_tracker": "https://github.com/jnxxx/homeassistant-connectedcars_io/issues", 14 | "requirements": [], 15 | "ssdp": [], 16 | "version": "1.1.3", 17 | "zeroconf": [] 18 | } 19 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "auth": "Authentication failed.", 5 | "email": "Email is unknown for this namespace.", 6 | "pw": "Incorrect password.", 7 | "ns": "Namespace could not be found." 8 | }, 9 | "step": { 10 | "user": { 11 | "data": { 12 | "email": "Email", 13 | "password": "Password", 14 | "namespace": "Namespace" 15 | }, 16 | "description": "Enter credentials.\n\nKnown namespaces: minvolkswagen, minskoda, minseat, mitaudi", 17 | "title": "Authentication" 18 | } 19 | } 20 | }, 21 | 22 | "options": { 23 | "error": { 24 | }, 25 | "step": { 26 | "init": { 27 | "data": { 28 | "health_sensitivity": "Choose sensitivity threshold of health sensor:" 29 | }, 30 | "description": "", 31 | "title": "Options" 32 | } 33 | } 34 | }, 35 | 36 | "selector": { 37 | "health_sensitivity": { 38 | "options": { 39 | "high": "High: Engine lamp and high severity error codes", 40 | "medium": "Medium: Any lamp, medium severity error codes and poor battery", 41 | "low": "Low: Any lamp, any error codes and poor battery", 42 | "all": "Any: Any indication" 43 | } 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "auth": "Authentication failed.", 5 | "email": "Email is unknown for this namespace.", 6 | "pw": "Incorrect password.", 7 | "ns": "Namespace could not be found." 8 | }, 9 | "step": { 10 | "user": { 11 | "data": { 12 | "email": "Email", 13 | "password": "Password", 14 | "namespace": "Namespace" 15 | }, 16 | "description": "Enter credentials.\n\nKnown namespaces: minvolkswagen, minskoda, minseat, mitaudi", 17 | "title": "Authentication" 18 | } 19 | } 20 | }, 21 | 22 | "options": { 23 | "error": { 24 | }, 25 | "step": { 26 | "init": { 27 | "data": { 28 | "health_sensitivity": "Choose sensitivity threshold of health sensor:" 29 | }, 30 | "description": "", 31 | "title": "Options" 32 | } 33 | } 34 | }, 35 | 36 | "selector": { 37 | "health_sensitivity": { 38 | "options": { 39 | "high": "High: Engine lamp and high severity error codes", 40 | "medium": "Medium: Any lamp, medium severity error codes and poor battery", 41 | "low": "Low: Any lamp, any error codes and poor battery", 42 | "all": "Any: Any indication" 43 | } 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for connectedcars.io / Min Volkswagen integration.""" 2 | 3 | import logging 4 | 5 | from homeassistant import config_entries, core 6 | 7 | from .const import CONF_HEALTH_SENSITIVITY, DOMAIN 8 | from .minvw import MinVW 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | PLATFORMS = ["binary_sensor", "device_tracker", "sensor"] 12 | 13 | 14 | async def async_setup_entry( 15 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 16 | ) -> bool: 17 | """Set up platform from a ConfigEntry.""" 18 | hass.data.setdefault(DOMAIN, {}) 19 | _LOGGER.debug("async_setup_entry: [%a][%s]", DOMAIN, entry.entry_id) 20 | 21 | data = {} 22 | data["email"] = entry.data["email"] 23 | data["password"] = entry.data["password"] 24 | data["namespace"] = entry.data["namespace"] 25 | data["connectedcarsclient"] = MinVW( 26 | entry.data["email"], entry.data["password"], entry.data["namespace"] 27 | ) 28 | data[CONF_HEALTH_SENSITIVITY] = entry.options.get(CONF_HEALTH_SENSITIVITY, "medium") 29 | 30 | # Registers update listener to update config entry when options are updated, and store a reference to the unsubscribe function 31 | data["unsub_options_update_listener"] = entry.add_update_listener( 32 | options_update_listener 33 | ) 34 | 35 | hass.data[DOMAIN][entry.entry_id] = data # entry.data 36 | 37 | # Forward the setup to the sensor platform. 38 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 39 | 40 | return True 41 | 42 | 43 | async def async_setup(hass: core.HomeAssistant, config: dict) -> bool: 44 | """Set up the GitHub Custom component from yaml configuration.""" 45 | hass.data.setdefault(DOMAIN, {}) 46 | return True 47 | 48 | 49 | async def options_update_listener( 50 | hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry 51 | ): 52 | """Handle options update.""" 53 | await hass.config_entries.async_reload(config_entry.entry_id) 54 | 55 | 56 | async def async_unload_entry( 57 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 58 | ) -> bool: 59 | """Unload a config entry.""" 60 | 61 | # data = hass.data[DOMAIN][entry.entry_id] 62 | # # Cancel previous timer 63 | # if ("timer_remove" in data) and (data["timer_remove"] is not None): 64 | # _LOGGER.debug("Remove timer") 65 | # data["timer_remove"]() 66 | 67 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 68 | 69 | # Remove options_update_listener. 70 | hass.data[DOMAIN][entry.entry_id]["unsub_options_update_listener"]() 71 | 72 | # Remove config entry from domain. 73 | if unload_ok: 74 | hass.data[DOMAIN].pop(entry.entry_id) 75 | 76 | return unload_ok 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Connectedcars.io (Min Volkswagen) 3 | 4 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) 5 | 6 | The `Connectedcars.io (Min Volkswagen)` component is a Home Assistant custom component for showing car information for Danish Volkswagens equipped with the hardware to send data to the mobile app "Min Volkswagen". 7 | Min Skoda, Min Seat and Mit Audi all use the same backend and should work as well, although still not tested. 8 | 9 | ## Installation 10 | --- 11 | ### Manual Installation 12 | 1. Copy `connectedcars_io` folder into your custom_components folder in your hass configuration directory. 13 | 2. Restart Home Assistant. 14 | 15 | ### Installation with HACS (Home Assistant Community Store) 16 | 1. Ensure that [HACS](https://hacs.xyz/) is installed. 17 | 2. In HACS / Integrations / Kebab menu / Custom repositories, add the url the this repository. 18 | 3. Search for and install the `Connectedcars.io (Min Volkswagen)` integration. 19 | 4. Restart Home Assistant. 20 | 21 | 22 | ## Configuration 23 | 24 | It is configurable through config flow, meaning it will popup a dialog after adding the integration. 25 | 1. Head to Configuration --> Integrations 26 | 2. Add new and search for `Connectedcars.io (Min Volkswagen)` 27 | 3. Enter credentials and namespace. 28 | 29 | #### Currently known namespaces 30 | - minvolkswagen *(default)* 31 | - minskoda 32 | - minseat 33 | - mitaudi 34 | 35 | #### Multiple cars 36 | If you have multiple cars on the same account, they should all appear. 37 | If you have multiple cars of different brands, add the integration multiple times each with the suitable namespace. 38 | *So far only tested with a single car* 39 | 40 | ## State and attributes 41 | A device is created for each car. 42 | For each car the following sensors can be created, but only when data is present. Thus fuel based cars should have fuel level sensors, while EVs should have battery sensors. 43 | 44 | The naming scheme is `{brand} {model} `. 45 | Sensor names: 46 | * BatteryVoltage (12V battery) 47 | * EVHVBattTemp (EV) 48 | * EVchargePercentage (EV) 49 | * fuelLevel 50 | * fuelPercentage 51 | * GeoLocation 52 | * Health (severity threshold configurable) 53 | * Attribute: Leads array may help to explain the cause 54 | * Ignition 55 | * Lamp *+name* (one sensor per each reported lamp, disabled by default) 56 | * NextServicePredicted (disabled by default) 57 | * odometer 58 | * outdoorTemperature 59 | * Speed 60 | * Fuel economy (disabled by default) 61 | * Mileage latest year (disabled by default) 62 | * Mileage latest month (disabled by default) 63 | * Mileage since refuel (disabled by default) 64 | 65 | All sensors may not be reported correctedly with all cars. 66 | Among others fuelPercentage is one of those. 67 | 68 | ## Debugging 69 | It is possible to debug log the raw response from the API. This is done by setting up logging like below in configuration.yaml in Home Assistant. It is also possible to set the log level through a service call in UI. 70 | 71 | ``` 72 | logger: 73 | default: info 74 | logs: 75 | custom_components.connectedcars_io: debug 76 | ``` 77 | 78 | ## Examples 79 | 80 | Configuration 81 | ![Config](https://github.com/jnxxx/homeassistant-connectedcars_io/raw/main/images/config.png) 82 | ![Options](https://github.com/jnxxx/homeassistant-connectedcars_io/raw/main/images/options.png) 83 | 84 | Device 85 | ![Device](https://github.com/jnxxx/homeassistant-connectedcars_io/raw/main/images/device.png) 86 | 87 | Dashboard 88 | ![Dashboard](https://github.com/jnxxx/homeassistant-connectedcars_io/raw/main/images/dashboard.png) 89 | 90 | Location state 91 | ![Location state](https://github.com/jnxxx/homeassistant-connectedcars_io/raw/main/images/location_state.png) 92 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/config_flow.py: -------------------------------------------------------------------------------- 1 | """Support for connectedcars.io / Min Volkswagen integration.""" 2 | 3 | import logging 4 | from typing import Any, Dict, Optional 5 | 6 | from homeassistant import config_entries 7 | from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PATH, CONF_PASSWORD 8 | from homeassistant.core import callback 9 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 10 | import homeassistant.helpers.selector as selector 11 | import homeassistant.helpers.config_validation as cv 12 | import voluptuous as vol 13 | 14 | from .const import DOMAIN, CONF_HEALTH_SENSITIVITY 15 | from .minvw import MinVW 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | AUTH_SCHEMA = vol.Schema( 20 | { 21 | vol.Required(CONF_EMAIL): cv.string, 22 | vol.Required(CONF_PASSWORD): cv.string, 23 | vol.Required("namespace", default="minvolkswagen"): cv.string, 24 | } 25 | ) 26 | 27 | from homeassistant.const import ( 28 | CONF_URL, 29 | CONF_SCAN_INTERVAL, 30 | ) 31 | 32 | 33 | # async def validate_auth(email: str, password: str, namespace: str, hass: core.HomeAssistant) -> None: 34 | 35 | # session = async_get_clientsession(hass) 36 | # gh = GitHubAPI(session, "requester", oauth_token=access_token) 37 | # try: 38 | # client = await MinVW(email, password, namespace) 39 | # except BadRequest: 40 | # raise ValueError 41 | 42 | 43 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 44 | """Github Custom config flow.""" 45 | 46 | data: Optional[dict[str, Any]] 47 | 48 | async def async_step_user(self, user_input: Optional[dict[str, Any]] = None): 49 | """Invoked when a user initiates a flow via the user interface.""" 50 | errors: dict[str, str] = {} 51 | if user_input is not None: 52 | try: 53 | client = MinVW( 54 | user_input[CONF_EMAIL], 55 | user_input[CONF_PASSWORD], 56 | user_input["namespace"], 57 | ) 58 | token = await client._get_access_token() 59 | 60 | except Exception as err: 61 | _LOGGER.debug(err) 62 | if str(err) == "Email is incorrect": 63 | errors[CONF_EMAIL] = "email" 64 | elif str(err) == "Incorrect password": 65 | errors[CONF_PASSWORD] = "pw" 66 | elif str(err) == "Namespace could not be found": 67 | errors["namespace"] = "ns" 68 | else: 69 | errors["base"] = "auth" 70 | if not errors: 71 | # Input is valid, set data. 72 | self.data = user_input 73 | # self.data[CONF_REPOS] = [] 74 | # Return the form of the next step. 75 | # return await self.async_step_repo() 76 | 77 | # User is done, create the config entry. 78 | return self.async_create_entry( 79 | title=user_input["namespace"], data=self.data 80 | ) 81 | 82 | return self.async_show_form( 83 | step_id="user", data_schema=AUTH_SCHEMA, errors=errors 84 | ) 85 | 86 | @staticmethod 87 | @callback 88 | def async_get_options_flow(config_entry): 89 | return OptionsFlowHandler(config_entry) 90 | 91 | 92 | class OptionsFlowHandler(config_entries.OptionsFlow): 93 | """dabblerdk_powermeterreader options flow.""" 94 | 95 | def __init__(self, config_entry) -> None: 96 | """Initialize options flow.""" 97 | self.config_entry = config_entry 98 | 99 | async def async_step_init( 100 | self, user_input: dict[str, Any] = None 101 | ) -> dict[str, Any]: 102 | """Manage the options.""" 103 | errors: dict[str, str] = {} 104 | 105 | if user_input is not None: 106 | # _LOGGER.warning( 107 | # "User input, selected options: %s", 108 | # user_input[CONF_HEALTH_SENSITIVITY], 109 | # ) 110 | 111 | if not errors: 112 | options = {} 113 | options[CONF_HEALTH_SENSITIVITY] = user_input[CONF_HEALTH_SENSITIVITY] 114 | 115 | return self.async_create_entry(title="", data=options) 116 | 117 | options_list = ["high", "medium", "low", "all"] 118 | options_schema = vol.Schema( 119 | { 120 | # vol.Required( 121 | # CONF_URL, default=self.config_entry.data[CONF_URL] 122 | # ): cv.string, 123 | vol.Required( 124 | CONF_HEALTH_SENSITIVITY, 125 | default=self.config_entry.options.get( 126 | CONF_HEALTH_SENSITIVITY, "medium" 127 | ), 128 | ): selector.SelectSelector( 129 | selector.SelectSelectorConfig( 130 | options=options_list, 131 | multiple=False, 132 | mode=selector.SelectSelectorMode.LIST, 133 | translation_key=CONF_HEALTH_SENSITIVITY, 134 | ), 135 | ), 136 | } 137 | ) 138 | return self.async_show_form( 139 | step_id="init", data_schema=options_schema, errors=errors 140 | ) 141 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Support for connectedcars.io / Min Volkswagen integration.""" 2 | 3 | from datetime import datetime, timedelta 4 | import logging 5 | import traceback 6 | 7 | from homeassistant import config_entries, core 8 | from homeassistant.components.device_tracker.config_entry import TrackerEntity 9 | from homeassistant.exceptions import PlatformNotReady 10 | 11 | from .const import DOMAIN 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | SCAN_INTERVAL = timedelta(minutes=1) 16 | 17 | 18 | async def async_setup_entry( 19 | hass: core.HomeAssistant, 20 | config_entry: config_entries.ConfigEntry, 21 | async_add_entities, 22 | ): 23 | """Set up the Connectedcars_io device_tracker platform.""" 24 | config = hass.data[DOMAIN][config_entry.entry_id] 25 | 26 | _connectedcarsclient = config["connectedcarsclient"] 27 | 28 | try: 29 | sensors = [] 30 | data = await _connectedcarsclient.get_vehicle_instances() 31 | for vehicle in data: 32 | if "GeoLocation" in vehicle["has"]: 33 | sensors.append( 34 | CcTrackerEntity(vehicle, "GeoLocation", _connectedcarsclient) 35 | ) 36 | async_add_entities(sensors, update_before_add=True) 37 | 38 | except Exception as err: 39 | _LOGGER.warning("Failed to add sensors: %s", err) 40 | _LOGGER.debug("%s", traceback.format_exc()) 41 | raise PlatformNotReady from err 42 | 43 | 44 | class CcTrackerEntity(TrackerEntity): 45 | """Representation of a Device TrackerEntity.""" 46 | 47 | def __init__(self, vehicle, itemName, connectedcarsclient) -> None: 48 | self._vehicle = vehicle 49 | self._itemName = itemName 50 | self._icon = "mdi:map" 51 | self._name = ( 52 | f"{self._vehicle['make']} {self._vehicle['model']} {self._itemName}" 53 | ) 54 | """Initialize.""" 55 | self._unique_id = f"{DOMAIN}-{self._vehicle['vin']}-{self._itemName}" 56 | self._device_class = None 57 | self._connectedcarsclient = connectedcarsclient 58 | self._latitude = None 59 | self._longitude = None 60 | self._cached_location = None 61 | self._cached_time = None 62 | self._updated = None 63 | _LOGGER.debug("Adding sensor: %s", self._unique_id) 64 | 65 | @property 66 | def device_info(self): 67 | """Device info.""" 68 | return { 69 | "identifiers": { 70 | # Serial numbers are unique identifiers within a specific domain 71 | (DOMAIN, self._vehicle["vin"]) 72 | }, 73 | "name": f"{self._vehicle['make']} {self._vehicle['model']}", # self._vehicle["name"], 74 | "manufacturer": self._vehicle["make"], 75 | "model": self._vehicle["name"] 76 | .removeprefix("VW") 77 | .removeprefix("Skoda") 78 | .removeprefix("Seat") 79 | .removeprefix("Audi") 80 | .strip(), 81 | "sw_version": self._vehicle["licensePlate"], 82 | } 83 | 84 | @property 85 | def name(self): 86 | """Return the name of the sensor.""" 87 | return self._name 88 | 89 | @property 90 | def icon(self): 91 | return self._icon 92 | 93 | @property 94 | def unique_id(self): 95 | """The unique id of the sensor.""" 96 | return self._unique_id 97 | 98 | @property 99 | def source_type(self) -> str: 100 | """Source type.""" 101 | return "gps" 102 | 103 | # @property 104 | # def location_accuracy(self) -> int: 105 | # return 1 106 | 107 | @property 108 | def latitude(self): 109 | """Latitude.""" 110 | return self._latitude 111 | 112 | @property 113 | def longitude(self): 114 | """Longitude.""" 115 | return self._longitude 116 | 117 | @property 118 | def available(self): 119 | """Availability.""" 120 | return self._latitude is not None and self._longitude is not None 121 | 122 | @property 123 | def device_class(self): 124 | """Device class.""" 125 | return self._device_class 126 | 127 | @property 128 | def should_poll(self) -> bool: 129 | """No polling for entities that have location pushed.""" 130 | return True 131 | 132 | # @property 133 | # def state(self): 134 | # _LOGGER.debug(f"zone_state...") 135 | # if self.latitude is not None and self.longitude is not None: 136 | # zone_state = zone.async_active_zone( 137 | # self.hass, self.latitude, self.longitude, self.location_accuracy 138 | # ) 139 | # _LOGGER.debug(f"zone_state: {zone_state}") 140 | # if zone_state is None: 141 | # state = STATE_NOT_HOME 142 | # elif zone_state.entity_id == zone.ENTITY_ID_HOME: 143 | # state = STATE_HOME 144 | # else: 145 | # state = zone_state.name 146 | # _LOGGER.debug(f"state: {state}") 147 | # return state 148 | # return None 149 | # return f"{self._latitude}, {self._longitude}" 150 | 151 | @property 152 | def extra_state_attributes(self): 153 | """Return state attributes.""" 154 | attributes = {} 155 | if self._updated is not None: 156 | attributes["Updated"] = self._updated 157 | return attributes 158 | 159 | async def async_update(self): 160 | """Update data.""" 161 | self._latitude = None 162 | self._longitude = None 163 | try: 164 | ignition = ( 165 | str( 166 | await self._connectedcarsclient.get_value( 167 | self._vehicle["id"], ["ignition", "on"] 168 | ) 169 | ).lower() 170 | == "true" 171 | ) 172 | ignition_time = None 173 | timestamp = await self._connectedcarsclient.get_value( 174 | self._vehicle["id"], ["ignition", "time"] 175 | ) 176 | if is_date_valid(timestamp): 177 | ignition_time = datetime.fromisoformat( 178 | str(timestamp).replace("Z", "+00:00") 179 | ) 180 | _LOGGER.debug("ignition: %s, time: %s", ignition, ignition_time) 181 | 182 | latitude = await self._connectedcarsclient.get_value_float( 183 | self._vehicle["id"], ["position", "latitude"] 184 | ) 185 | longitude = await self._connectedcarsclient.get_value_float( 186 | self._vehicle["id"], ["position", "longitude"] 187 | ) 188 | postime = await self._connectedcarsclient.get_value( 189 | self._vehicle["id"], ["position", "time"] 190 | ) 191 | position = tuple((latitude, longitude)) 192 | 193 | if ignition: 194 | self._cached_location = None 195 | self._cached_time = None 196 | self._updated = postime 197 | else: 198 | if ( 199 | self._cached_location is None 200 | or ignition_time is None 201 | or self._cached_time is None 202 | or ignition_time > self._cached_time 203 | ): 204 | self._cached_location = position 205 | self._cached_time = ignition_time 206 | self._updated = postime 207 | _LOGGER.debug("position: %s", position) 208 | else: 209 | position = self._cached_location 210 | _LOGGER.debug("cached_location: %s", position) 211 | 212 | self._latitude = position[0] 213 | self._longitude = position[1] 214 | 215 | except Exception as err: # pylint: disable=broad-except 216 | _LOGGER.debug("Unable to get vehicle location: %s", err) 217 | 218 | 219 | def is_date_valid(date) -> bool: 220 | """Check if date is valid.""" 221 | valid_date = True 222 | try: 223 | valid_date = ( 224 | False 225 | if (date is None) 226 | else bool(datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%f%z")) 227 | ) 228 | except ValueError: 229 | valid_date = False 230 | return valid_date 231 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for connectedcars.io / Min Volkswagen integration.""" 2 | 3 | from datetime import timedelta 4 | import logging 5 | import traceback 6 | 7 | from homeassistant import config_entries, core 8 | from homeassistant.components.binary_sensor import BinarySensorEntity 9 | 10 | # , BinarySensorEntityDescription 11 | from homeassistant.exceptions import PlatformNotReady 12 | 13 | from .const import CONF_HEALTH_SENSITIVITY, DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | SCAN_INTERVAL = timedelta(minutes=1) 18 | 19 | 20 | async def async_setup_entry( 21 | hass: core.HomeAssistant, 22 | config_entry: config_entries.ConfigEntry, 23 | async_add_entities, 24 | ): 25 | """Set up the Connectedcars_io binary_sensor platform.""" 26 | config = hass.data[DOMAIN][config_entry.entry_id] 27 | 28 | _connectedcarsclient = config["connectedcarsclient"] 29 | 30 | try: 31 | sensors = [] 32 | data = await _connectedcarsclient.get_vehicle_instances() 33 | for vehicle in data: 34 | if "Ignition" in vehicle["has"]: 35 | sensors.append( 36 | CcBinaryEntity( 37 | vehicle, "Ignition", "", "moving", True, _connectedcarsclient 38 | ) 39 | ) 40 | if "Health" in vehicle["has"]: 41 | sensors.append( 42 | CcBinaryEntity( 43 | vehicle, 44 | "Health", 45 | "", 46 | "problem", 47 | True, 48 | _connectedcarsclient, 49 | config[CONF_HEALTH_SENSITIVITY], 50 | ) 51 | ) 52 | for lampState in vehicle["lampStates"]: 53 | sensors.append( 54 | CcBinaryEntity( 55 | vehicle, 56 | "Lamp", 57 | lampState, 58 | "problem", 59 | False, 60 | _connectedcarsclient, 61 | ) 62 | ) 63 | async_add_entities(sensors, update_before_add=True) 64 | 65 | except Exception as err: 66 | _LOGGER.warning("Failed to add sensors: %s", err) 67 | _LOGGER.debug("%s", traceback.format_exc()) 68 | raise PlatformNotReady from err 69 | 70 | 71 | class CcBinaryEntity(BinarySensorEntity): 72 | """Representation of a BinaryEntity.""" 73 | 74 | def __init__( 75 | self, 76 | vehicle, 77 | itemName, 78 | subitemName, 79 | device_class, 80 | entity_registry_enabled_default, 81 | connectedcarsclient, 82 | sensitivity=None, 83 | ) -> None: 84 | """Initialize the sensor.""" 85 | self._vehicle = vehicle 86 | self._itemName = itemName 87 | self._subitemName = subitemName 88 | # self._icon = "mdi:map" 89 | self._name = f"{self._vehicle['make']} {self._vehicle['model']} {self._itemName}{self._subitemName.capitalize()}" 90 | self._unique_id = f"{DOMAIN}-{self._vehicle['vin']}-{self._itemName}{self._subitemName.capitalize()}" 91 | self._device_class = device_class 92 | self._connectedcarsclient = connectedcarsclient 93 | self._sensitivity = sensitivity 94 | self._is_on = None 95 | self._entity_registry_enabled_default = entity_registry_enabled_default 96 | self._dict = {} 97 | self._updated = None 98 | _LOGGER.debug("Adding sensor: %s", self._unique_id) 99 | 100 | @property 101 | def device_info(self): 102 | """Device info.""" 103 | return { 104 | "identifiers": {(DOMAIN, self._vehicle["vin"])}, 105 | "name": f"{self._vehicle['make']} {self._vehicle['model']}", # self._vehicle["name"], 106 | "manufacturer": self._vehicle["make"], 107 | "model": self._vehicle["name"] 108 | .removeprefix("VW") 109 | .removeprefix("Skoda") 110 | .removeprefix("Seat") 111 | .removeprefix("Audi") 112 | .strip(), 113 | "sw_version": self._vehicle["licensePlate"], 114 | } 115 | 116 | @property 117 | def name(self): 118 | """Return the name of the sensor.""" 119 | return self._name 120 | 121 | @property 122 | def entity_registry_enabled_default(self): 123 | """Enabled by default.""" 124 | return self._entity_registry_enabled_default 125 | 126 | # @property 127 | # def entity_description(self): 128 | # _LOGGER.debug(f"entity_description") 129 | # ret = BinarySensorEntityDescription(key="desc_key", name="desc_name") 130 | # return (ret) 131 | 132 | # @property 133 | # def icon(self): 134 | # return self._icon 135 | 136 | @property 137 | def unique_id(self): 138 | """The unique id of the sensor.""" 139 | return self._unique_id 140 | 141 | @property 142 | def is_on(self): 143 | """State.""" 144 | return self._is_on 145 | 146 | @property 147 | def available(self): 148 | """Availability.""" 149 | return self._is_on is not None 150 | 151 | @property 152 | def device_class(self): 153 | """Device class.""" 154 | return self._device_class 155 | 156 | @property 157 | def extra_state_attributes(self): 158 | """Return state attributes.""" 159 | attributes = {} 160 | if self._updated is not None: 161 | attributes["Updated"] = self._updated 162 | attributes.update(self._dict) 163 | return attributes 164 | 165 | async def async_update(self): 166 | """Update data.""" 167 | self._is_on = None 168 | try: 169 | if self._itemName == "Ignition": 170 | self._is_on = ( 171 | str( 172 | await self._connectedcarsclient.get_value( 173 | self._vehicle["id"], ["ignition", "on"] 174 | ) 175 | ).lower() 176 | == "true" 177 | ) 178 | self._updated = await self._connectedcarsclient.get_value( 179 | self._vehicle["id"], ["ignition", "time"] 180 | ) 181 | elif self._itemName == "Health": 182 | # self._is_on = ( 183 | # str( 184 | # await self._connectedcarsclient.get_value( 185 | # self._vehicle["id"], ["health", "ok"] 186 | # ) 187 | # ).lower() 188 | # != "true" 189 | # ) 190 | self._dict["Leads"] = await self._connectedcarsclient.get_leads( 191 | self._vehicle["id"] 192 | ) 193 | self._is_on = self.evaluate_health() 194 | 195 | elif self._itemName == "Lamp": 196 | enabled, self._updated = await self._connectedcarsclient.get_lampstatus( 197 | self._vehicle["id"], self._subitemName 198 | ) 199 | self._is_on = str(enabled).lower() == "true" 200 | 201 | except Exception as err: # pylint: disable=broad-except 202 | _LOGGER.debug("Unable to get binary state: %s", err) 203 | 204 | def evaluate_health(self): 205 | """Evaluate health.""" 206 | ret = False 207 | for lead in self._dict["Leads"]: 208 | if "type" in lead and lead["type"] is not None: 209 | t = lead["type"] 210 | if self._sensitivity == "high" and t in ( 211 | "error_code_high", 212 | "lamp_engine_lamp", 213 | ): 214 | ret = True 215 | elif self._sensitivity == "medium" and ( 216 | t 217 | in ( 218 | "error_code_high", 219 | "error_code_medium", 220 | "poor_battery", 221 | ) 222 | or t.startswith("lamp_") 223 | ): 224 | ret = True 225 | elif self._sensitivity == "low" and ( 226 | t 227 | in ( 228 | "error_code_high", 229 | "error_code_medium", 230 | "error_code", 231 | "poor_battery", 232 | ) 233 | or t.startswith("lamp_") 234 | ): 235 | ret = True 236 | elif self._sensitivity == "all": 237 | ret = True 238 | return ret 239 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for connectedcars.io / Min Volkswagen integration.""" 2 | 3 | from datetime import UTC, datetime, timedelta, timezone 4 | import logging 5 | import traceback 6 | 7 | from homeassistant import config_entries, core 8 | from homeassistant.components.sensor import ( 9 | RestoreSensor, 10 | SensorDeviceClass, 11 | SensorEntity, 12 | # SensorEntityDescription, 13 | SensorStateClass, 14 | ) 15 | from homeassistant.const import ( 16 | PERCENTAGE, 17 | STATE_UNAVAILABLE, 18 | STATE_UNKNOWN, 19 | UnitOfElectricPotential, 20 | UnitOfLength, 21 | UnitOfSpeed, 22 | UnitOfTemperature, 23 | UnitOfVolume, 24 | ) 25 | 26 | # from homeassistant.helpers.entity import Entity 27 | from homeassistant.exceptions import PlatformNotReady 28 | from homeassistant.helpers import device_registry as dr 29 | 30 | from .const import DOMAIN 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | SCAN_INTERVAL = timedelta(minutes=1) 35 | 36 | 37 | async def async_setup_entry( 38 | hass: core.HomeAssistant, 39 | config_entry: config_entries.ConfigEntry, 40 | async_add_entities, 41 | ): 42 | """Set up the Connectedcars_io sensor platform.""" 43 | config = hass.data[DOMAIN][config_entry.entry_id] 44 | 45 | _connectedcarsclient = config["connectedcarsclient"] 46 | 47 | try: 48 | sensors = [] 49 | sensors_update_later = [] 50 | data = await _connectedcarsclient.get_vehicle_instances(True) 51 | for vehicle in data: 52 | if "outdoorTemperature" in vehicle["has"]: 53 | sensors.append( 54 | MinVwEntity( 55 | vehicle, "outdoorTemperature", True, _connectedcarsclient 56 | ) 57 | ) 58 | if "BatteryVoltage" in vehicle["has"]: 59 | sensors.append( 60 | MinVwEntity(vehicle, "BatteryVoltage", True, _connectedcarsclient) 61 | ) 62 | if "odometer" in vehicle["has"]: 63 | sensors.append( 64 | MinVwEntity(vehicle, "odometer", True, _connectedcarsclient) 65 | ) 66 | if "fuelPercentage" in vehicle["has"]: 67 | sensors.append( 68 | MinVwEntity(vehicle, "fuelPercentage", True, _connectedcarsclient) 69 | ) 70 | if "fuelLevel" in vehicle["has"]: 71 | sensors.append( 72 | MinVwEntity(vehicle, "fuelLevel", True, _connectedcarsclient) 73 | ) 74 | if "fuelEconomy" in vehicle["has"]: 75 | sensors.append( 76 | MinVwEntity(vehicle, "fuel economy", False, _connectedcarsclient) 77 | ) 78 | if "NextServicePredicted" in vehicle["has"]: 79 | sensors.append( 80 | MinVwEntity( 81 | vehicle, "NextServicePredicted", False, _connectedcarsclient 82 | ) 83 | ) 84 | if "EVchargePercentage" in vehicle["has"]: 85 | sensors.append( 86 | MinVwEntity( 87 | vehicle, "EVchargePercentage", True, _connectedcarsclient 88 | ) 89 | ) 90 | if "EVHVBattTemp" in vehicle["has"]: 91 | sensors.append( 92 | MinVwEntity(vehicle, "EVHVBattTemp", True, _connectedcarsclient) 93 | ) 94 | if "RangeTotal" in vehicle["has"]: 95 | sensors.append( 96 | MinVwEntity(vehicle, "Range", False, _connectedcarsclient) 97 | ) 98 | if "Speed" in vehicle["has"]: 99 | sensors.append( 100 | MinVwEntity(vehicle, "Speed", True, _connectedcarsclient) 101 | ) 102 | if "totalTripStatistics" in vehicle["has"]: 103 | sensors.append( 104 | MinVwEntity( 105 | vehicle, "mileage latest year", False, _connectedcarsclient 106 | ) 107 | ) 108 | sensors.append( 109 | MinVwEntity( 110 | vehicle, "mileage latest month", False, _connectedcarsclient 111 | ) 112 | ) 113 | if ( 114 | "refuelEvents" in vehicle["has"] 115 | and "trips" in vehicle["has"] 116 | and "odometer" in vehicle["has"] 117 | ): 118 | sensors_update_later.append( 119 | MinVwEntityRestore( 120 | vehicle, "mileage since refuel", False, _connectedcarsclient 121 | ) 122 | ) 123 | async_add_entities(sensors, update_before_add=True) 124 | async_add_entities(sensors_update_later, update_before_add=False) 125 | 126 | except Exception as err: 127 | _LOGGER.warning("Failed to add sensors: %s", err) 128 | _LOGGER.debug("%s", traceback.format_exc()) 129 | raise PlatformNotReady from err 130 | 131 | # Build array with devices to keep 132 | devices = [(DOMAIN, vehicle["vin"]) for vehicle in data] 133 | # devices = [] 134 | # for vehicle in data: 135 | # devices.append((DOMAIN, vehicle["vin"])) 136 | 137 | # Remove devices no longer reported 138 | device_registry = dr.async_get(hass) 139 | for device_entry in dr.async_entries_for_config_entry( 140 | device_registry, config_entry.entry_id 141 | ): 142 | for identifier in device_entry.identifiers: 143 | if identifier not in devices: 144 | _LOGGER.warning("Removing device: %s", identifier) 145 | device_registry.async_remove_device(device_entry.id) 146 | 147 | 148 | class MinVwEntity(SensorEntity): 149 | """Representation of a Sensor.""" 150 | 151 | def __init__( 152 | self, vehicle, itemName, entity_registry_enabled_default, connectedcarsclient 153 | ) -> None: 154 | """Initialize the sensor.""" 155 | self._state = None 156 | self._data_date = None 157 | self._unit = None 158 | self._vehicle = vehicle 159 | self._itemName = itemName 160 | self._icon = "mdi:car" 161 | self._suggested_display_precision = None 162 | self._name = ( 163 | f"{self._vehicle['make']} {self._vehicle['model']} {self._itemName}" 164 | ) 165 | self._unique_id = f"{DOMAIN}-{self._vehicle['vin']}-{self._itemName}" 166 | self._device_class = None 167 | self._connectedcarsclient = connectedcarsclient 168 | self._entity_registry_enabled_default = entity_registry_enabled_default 169 | self._dict = {} 170 | self._updated = None 171 | 172 | if self._itemName == "outdoorTemperature": 173 | self._unit = UnitOfTemperature.CELSIUS 174 | self._icon = "mdi:thermometer" 175 | self._device_class = SensorDeviceClass.TEMPERATURE 176 | elif self._itemName == "BatteryVoltage": 177 | self._unit = UnitOfElectricPotential.VOLT 178 | self._icon = "mdi:car-battery" 179 | self._device_class = SensorDeviceClass.VOLTAGE 180 | elif self._itemName == "fuelPercentage": 181 | self._unit = PERCENTAGE 182 | self._icon = "mdi:gas-station" 183 | # self._device_class = SensorDeviceClass. 184 | elif self._itemName == "fuelLevel": 185 | self._unit = UnitOfVolume.LITERS 186 | self._icon = "mdi:gas-station" 187 | self._device_class = SensorDeviceClass.VOLUME 188 | elif self._itemName == "odometer": 189 | self._unit = UnitOfLength.KILOMETERS 190 | self._icon = "mdi:counter" 191 | self._device_class = SensorDeviceClass.DISTANCE 192 | self._attr_state_class = SensorStateClass.TOTAL 193 | elif self._itemName == "NextServicePredicted": 194 | # self._unit = ATTR_LOCATION 195 | self._icon = "mdi:wrench" 196 | self._device_class = SensorDeviceClass.DATE 197 | elif self._itemName == "EVchargePercentage": 198 | self._unit = PERCENTAGE 199 | self._icon = "mdi:battery" 200 | self._device_class = SensorDeviceClass.BATTERY 201 | elif self._itemName == "EVHVBattTemp": 202 | self._unit = UnitOfTemperature.CELSIUS 203 | self._icon = "mdi:thermometer" 204 | self._device_class = SensorDeviceClass.TEMPERATURE 205 | elif self._itemName == "Range": 206 | self._unit = UnitOfLength.KILOMETERS 207 | self._icon = "mdi:map-marker-distance" 208 | self._device_class = SensorDeviceClass.DISTANCE 209 | elif self._itemName == "Speed": 210 | self._unit = UnitOfSpeed.KILOMETERS_PER_HOUR 211 | self._icon = "mdi:speedometer" 212 | self._device_class = SensorDeviceClass.SPEED 213 | elif self._itemName == "mileage latest year": 214 | self._unit = UnitOfLength.KILOMETERS 215 | self._icon = "mdi:counter" 216 | self._device_class = SensorDeviceClass.DISTANCE 217 | elif self._itemName == "mileage latest month": 218 | self._unit = UnitOfLength.KILOMETERS 219 | self._icon = "mdi:counter" 220 | self._device_class = SensorDeviceClass.DISTANCE 221 | elif self._itemName == "mileage since refuel": 222 | self._unit = UnitOfLength.KILOMETERS 223 | self._icon = "mdi:counter" 224 | self._device_class = SensorDeviceClass.DISTANCE 225 | elif self._itemName == "fuel economy": 226 | self._unit = "km/l" 227 | self._icon = "mdi:gas-station-outline" 228 | self._suggested_display_precision = 1 229 | 230 | _LOGGER.debug("Adding sensor: %s", self._unique_id) 231 | 232 | @property 233 | def device_info(self): 234 | """Device info.""" 235 | return { 236 | "identifiers": { 237 | # Serial numbers are unique identifiers within a specific domain 238 | (DOMAIN, self._vehicle["vin"]) 239 | }, 240 | "name": f"{self._vehicle['make']} {self._vehicle['model']}", # self._vehicle["name"], 241 | "manufacturer": self._vehicle["make"], 242 | "model": self._vehicle["name"] 243 | .removeprefix("VW") 244 | .removeprefix("Skoda") 245 | .removeprefix("Seat") 246 | .removeprefix("Audi") 247 | .strip(), 248 | "sw_version": self._vehicle["licensePlate"], 249 | } 250 | 251 | @property 252 | def name(self): 253 | """Return the name of the sensor.""" 254 | return self._name 255 | 256 | @property 257 | def entity_registry_enabled_default(self): 258 | """Enabled by default.""" 259 | return self._entity_registry_enabled_default 260 | 261 | @property 262 | def icon(self): 263 | """Icon.""" 264 | return self._icon 265 | 266 | @property 267 | def unique_id(self): 268 | """The unique id of the sensor.""" 269 | return self._unique_id 270 | 271 | @property 272 | def state(self): 273 | """Return the state of the sensor.""" 274 | return self._state 275 | 276 | @property 277 | def available(self): 278 | """Availability.""" 279 | return self._state is not None 280 | 281 | @property 282 | def device_class(self): 283 | """Device class.""" 284 | return self._device_class 285 | 286 | @property 287 | def extra_state_attributes(self): 288 | """Return state attributes.""" 289 | attributes = {} 290 | # attributes['state_class'] = self._state_class 291 | # if self._device_class is not None: 292 | # attributes['device_class'] = self._device_class 293 | if self._updated is not None: 294 | attributes["Updated"] = self._updated 295 | attributes.update(self._dict) 296 | # for key in self._dict: 297 | # attributes[key] = self._dict[key] 298 | return attributes 299 | 300 | @property 301 | def unit_of_measurement(self): 302 | """Return the unit of measurement.""" 303 | return self._unit 304 | 305 | @property 306 | def suggested_display_precision(self): 307 | """Return the suggested_display_precision.""" 308 | return self._suggested_display_precision 309 | 310 | async def async_update(self): 311 | """Fetch new state data for the sensor. 312 | 313 | This is the only method that should fetch new data for Home Assistant. 314 | """ 315 | # _LOGGER.debug(f"Setting status for {self._name}") 316 | 317 | if self._itemName == "outdoorTemperature": 318 | self._state = await self._connectedcarsclient.get_value( 319 | self._vehicle["id"], ["outdoorTemperatures", 0, "celsius"] 320 | ) 321 | self._updated = await self._connectedcarsclient.get_value( 322 | self._vehicle["id"], ["outdoorTemperatures", 0, "time"] 323 | ) 324 | if self._itemName == "BatteryVoltage": 325 | self._state = await self._connectedcarsclient.get_value( 326 | self._vehicle["id"], ["latestBatteryVoltage", "voltage"] 327 | ) 328 | self._updated = await self._connectedcarsclient.get_value( 329 | self._vehicle["id"], ["latestBatteryVoltage", "time"] 330 | ) 331 | if self._itemName == "fuelPercentage": 332 | self._state = await self._connectedcarsclient.get_value( 333 | self._vehicle["id"], ["fuelPercentage", "percent"] 334 | ) 335 | self._updated = await self._connectedcarsclient.get_value( 336 | self._vehicle["id"], ["fuelPercentage", "time"] 337 | ) 338 | if self._itemName == "fuelLevel": 339 | self._state = await self._connectedcarsclient.get_value( 340 | self._vehicle["id"], ["fuelLevel", "liter"] 341 | ) 342 | self._updated = await self._connectedcarsclient.get_value( 343 | self._vehicle["id"], ["fuelLevel", "time"] 344 | ) 345 | if self._itemName == "odometer": 346 | self._state = await self._connectedcarsclient.get_value( 347 | self._vehicle["id"], ["odometer", "odometer"] 348 | ) 349 | self._updated = await self._connectedcarsclient.get_value( 350 | self._vehicle["id"], ["odometer", "time"] 351 | ) 352 | if self._itemName == "NextServicePredicted": 353 | self._state = ( 354 | await self._connectedcarsclient.get_next_service_data_predicted( 355 | self._vehicle["id"] 356 | ) 357 | ) 358 | if self._itemName == "Speed": 359 | self._state = await self._connectedcarsclient.get_value( 360 | self._vehicle["id"], ["position", "speed"] 361 | ) 362 | self._dict["Direction"] = await self._connectedcarsclient.get_value( 363 | self._vehicle["id"], ["position", "direction"] 364 | ) 365 | self._updated = await self._connectedcarsclient.get_value( 366 | self._vehicle["id"], ["position", "time"] 367 | ) 368 | if self._itemName == "mileage latest year" and ( 369 | self._data_date is None 370 | or datetime.now(UTC) >= self._data_date + timedelta(hours=1) 371 | ): 372 | ( 373 | self._state, 374 | self._dict, 375 | ) = await self._connectedcarsclient.get_latest_years_mileage( 376 | self._vehicle["id"], False 377 | ) 378 | if self._state is not None: 379 | self._data_date = datetime.now(UTC) 380 | if self._itemName == "mileage latest month" and ( 381 | self._data_date is None 382 | or datetime.now(UTC) >= self._data_date + timedelta(hours=1) 383 | ): 384 | ( 385 | self._state, 386 | self._dict, 387 | ) = await self._connectedcarsclient.get_latest_years_mileage( 388 | self._vehicle["id"], True 389 | ) 390 | if self._state is not None: 391 | self._data_date = datetime.now(UTC) 392 | if self._itemName == "mileage since refuel": 393 | self._state = None 394 | 395 | refuel_event_time = await self._connectedcarsclient.get_value( 396 | self._vehicle["id"], ["refuelEvents", 0, "time"] 397 | ) 398 | valid_date = is_date_valid(refuel_event_time) 399 | if valid_date: 400 | # Has refuel timestamp changed? 401 | if ( 402 | "Refueled at" not in self._dict 403 | or self._dict["Refueled at"] is None 404 | or refuel_event_time != self._dict["Refueled at"] 405 | ): 406 | _LOGGER.debug("Refuel event detected") 407 | self._dict["Refueled at"] = refuel_event_time 408 | self._dict["Odometer"] = None 409 | 410 | # Do we have odometer value corresponding to refuel timestamp? 411 | if "Odometer" not in self._dict or self._dict["Odometer"] is None: 412 | trip = await self._connectedcarsclient.get_trip_at_time( 413 | self._vehicle["id"], refuel_event_time 414 | ) 415 | if ( 416 | trip is not None 417 | and "startOdometer" in trip 418 | and trip["startOdometer"] is not None 419 | ): 420 | _LOGGER.debug( 421 | "Got odometer value at refuel event: %s", 422 | trip["startOdometer"], 423 | ) 424 | self._dict["Odometer"] = trip["startOdometer"] 425 | 426 | # Subtract refuel odometer from current odometer 427 | if "Odometer" in self._dict and self._dict["Odometer"] is not None: 428 | odometer_current = await self._connectedcarsclient.get_value( 429 | self._vehicle["id"], ["odometer", "odometer"] 430 | ) 431 | if odometer_current is not None: 432 | distance_since_refuel = odometer_current - self._dict["Odometer"] 433 | if distance_since_refuel >= 0: 434 | self._state = distance_since_refuel 435 | 436 | # ignition = ( 437 | # str( 438 | # await self._connectedcarsclient.get_value( 439 | # self._vehicle["id"], ["ignition", "on"] 440 | # ) 441 | # ).lower() 442 | # == "true" 443 | # ) 444 | # try: 445 | # ignition_time = datetime.fromisoformat( 446 | # str( 447 | # await self._connectedcarsclient.get_value( 448 | # self._vehicle["id"], ["ignition", "time"] 449 | # ) 450 | # ).replace("Z", "+00:00") 451 | # ) 452 | # except Exception as err: # pylint: disable=broad-except 453 | # _LOGGER.warning("Unable to parse ignition timestamp. Err: %s", err) 454 | # _LOGGER.debug("ignition: %s, time: %s", ignition, ignition_time) 455 | 456 | # if ( 457 | # self._data_date is None 458 | # or datetime.utcnow() >= self._data_date + timedelta(hours=1) 459 | # or ( 460 | # not ignition 461 | # and ignition_time > self._data_date.replace(tzinfo=timezone.utc) 462 | # ) 463 | # ): 464 | # ( 465 | # self._state, 466 | # self._dict, 467 | # ) = await self._connectedcarsclient.get_mileage_since_refuel( 468 | # self._vehicle["id"] 469 | # ) 470 | # _LOGGER.debug("5") 471 | # if self._state is not None: 472 | # self._data_date = datetime.utcnow() 473 | 474 | if self._itemName == "fuel economy": 475 | self._state = await self._connectedcarsclient.get_value( 476 | self._vehicle["id"], ["fuelEconomy"] 477 | ) 478 | # if fuelEconomy is not None: 479 | # fuelEconomy = round(fuelEconomy, 1) 480 | # self._state = fuelEconomy 481 | 482 | # EV 483 | if self._itemName == "EVchargePercentage": 484 | self._state = await self._connectedcarsclient.get_value( 485 | self._vehicle["id"], ["chargePercentage", "pct"] 486 | ) 487 | self._updated = await self._connectedcarsclient.get_value( 488 | self._vehicle["id"], ["chargePercentage", "time"] 489 | ) 490 | 491 | batlevel = round(self._state / 10) * 10 492 | if batlevel == 100: 493 | self._icon = "mdi:battery" 494 | elif batlevel == 0: 495 | self._icon = "mdi:battery-outline" 496 | else: 497 | self._icon = f"mdi:battery-{batlevel}" 498 | if self._itemName == "EVHVBattTemp": 499 | self._state = await self._connectedcarsclient.get_value( 500 | self._vehicle["id"], ["highVoltageBatteryTemperature", "celsius"] 501 | ) 502 | self._updated = await self._connectedcarsclient.get_value( 503 | self._vehicle["id"], ["highVoltageBatteryTemperature", "time"] 504 | ) 505 | if self._itemName == "Range": 506 | self._state = await self._connectedcarsclient.get_value( 507 | self._vehicle["id"], ["rangeTotalKm", "km"] 508 | ) 509 | self._updated = await self._connectedcarsclient.get_value( 510 | self._vehicle["id"], ["rangeTotalKm", "time"] 511 | ) 512 | 513 | 514 | def is_date_valid(date) -> bool: 515 | """Check date validity.""" 516 | valid_date = True 517 | try: 518 | valid_date = ( 519 | False 520 | if (date is None) 521 | else bool(datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%f%z")) 522 | ) 523 | except ValueError: 524 | valid_date = False 525 | return valid_date 526 | 527 | 528 | class MinVwEntityRestore(MinVwEntity, RestoreSensor): 529 | """Representation of a restoring sensor.""" 530 | 531 | # def __init__( 532 | # self, vehicle, itemName, entity_registry_enabled_default, connectedcarsclient 533 | # ): 534 | # """Inherited""" 535 | # super().__init__( 536 | # vehicle, itemName, entity_registry_enabled_default, connectedcarsclient 537 | # ) 538 | 539 | async def async_added_to_hass(self): 540 | """Handle entity which will be added.""" 541 | await super().async_added_to_hass() 542 | 543 | if ( 544 | (last_state := await self.async_get_last_state()) is not None 545 | # and (extra_data := await self.async_get_last_sensor_data()) is not None 546 | and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) 547 | # The trigger might have fired already while we waited for stored data, 548 | # then we should not restore state 549 | # and CONF_STATE not in self._rendered 550 | ): 551 | _LOGGER.debug( 552 | "Read previously stored state and attributes for sensor: %s", 553 | self._unique_id, 554 | ) 555 | self._state = last_state.state 556 | 557 | for key in last_state.attributes: 558 | if key not in [ 559 | "unit_of_measurement", 560 | "device_class", 561 | "icon", 562 | "friendly_name", 563 | ]: 564 | self._dict[key] = last_state.attributes[key] 565 | _LOGGER.debug("State: %s, Attributes: %s", last_state.state, self._dict) 566 | 567 | await MinVwEntity.async_update(self) 568 | self.async_write_ha_state() 569 | 570 | # async def async_get_last_sensor_data(self): 571 | # """Restore Utility Meter Sensor Extra Stored Data.""" 572 | # _LOGGER.debug("2") 573 | # if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: 574 | # return None 575 | 576 | # _LOGGER.debug("3") 577 | # return restored_last_extra_data.as_dict() 578 | -------------------------------------------------------------------------------- /custom_components/connectedcars_io/minvw/minvw.py: -------------------------------------------------------------------------------- 1 | """Wrapper for connectedcars.io.""" 2 | 3 | import asyncio 4 | from datetime import UTC, datetime, timedelta 5 | import json 6 | import logging 7 | import traceback 8 | 9 | import aiohttp 10 | from dateutil.relativedelta import relativedelta 11 | 12 | # import hashlib 13 | 14 | # Test 15 | # import random 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class MinVW: 21 | """Primary exported interface for connectedcars.io API wrapper.""" 22 | 23 | def __init__(self, email, password, namespace) -> None: 24 | """Initialize.""" 25 | self._email = email 26 | self._password = password 27 | self._namespace = namespace 28 | self._base_url_auth = "https://auth-api.connectedcars.io/" 29 | self._base_url_graph = "https://api.connectedcars.io/" 30 | self._accesstoken = None 31 | self._at_expires = None 32 | self._data = None 33 | self._data_expires = None 34 | self._lock_update = asyncio.Lock() 35 | 36 | async def get_next_service_data_predicted(self, vehicle_id): 37 | """Calculate number of days until next service. Prodicted.""" 38 | ret = None 39 | date_str = await self.get_value(vehicle_id, ["service", "predictedDate"]) 40 | 41 | if date_str is not None: 42 | ret = datetime.strptime(date_str, "%Y-%m-%d").date() 43 | # ret = (date - datetime.now().date()).days 44 | # if ret < 0: 45 | # ret = 0 46 | return ret 47 | 48 | async def api_request(self, req_param): 49 | """Make an API request for data.""" 50 | ret = None 51 | 52 | try: 53 | async with self._lock_update: 54 | headers = { 55 | "Content-Type": "application/json", 56 | "Accept": "application/json", 57 | "x-organization-namespace": f"semler:{self._namespace}", 58 | "User-Agent": "ConnectedCars/360 CFNetwork/978.0.7 Darwin/18.7.0", 59 | "Authorization": f"Bearer {await self._get_access_token()}", 60 | } 61 | 62 | req_body = {"query": req_param} 63 | req_url = self._base_url_graph + "graphql" 64 | 65 | async with ( 66 | aiohttp.ClientSession() as session, 67 | session.post(req_url, json=req_body, headers=headers) as response, 68 | ): 69 | if response.ok: 70 | ret = await response.json() 71 | else: 72 | _LOGGER.warning( 73 | "Unexpected response: %s", await response.read() 74 | ) 75 | 76 | # async with aiohttp.ClientSession() as session: 77 | # async with session.post( 78 | # req_url, json=req_body, headers=headers 79 | # ) as response: 80 | # if response.ok: 81 | # ret = await response.json() 82 | # else: 83 | # _LOGGER.warning( 84 | # "Unexpected response: %s", await response.read() 85 | # ) 86 | 87 | except aiohttp.ClientConnectionError as err: 88 | _LOGGER.warning("Connection error: %s", str(err)) 89 | _LOGGER.debug("%s", traceback.format_exc()) 90 | 91 | return ret 92 | 93 | async def get_latest_years_mileage(self, vehicle_id, latest_month): 94 | """Get mileage for latest year or month.""" 95 | ret = None 96 | att = {} 97 | 98 | req_param = """query YearlyMileage { 99 | vehicle(id: %s) { 100 | 101 | totalTripStatistics(period: {first: "%s", last: "%s"} ) {mileageInKm, driveDurationInMinutes, numberTrips, longestMileageInKm} 102 | } 103 | } 104 | """ 105 | 106 | date = datetime.now(UTC) # datetime.utcnow() 107 | time_delta = relativedelta(years=-1) 108 | if latest_month: 109 | time_delta = relativedelta(months=-1) 110 | 111 | req_param = req_param % ( 112 | vehicle_id, 113 | (date + time_delta) 114 | .isoformat(timespec="milliseconds") 115 | .replace("+00:00", "Z"), 116 | date.isoformat(timespec="milliseconds").replace("+00:00", "Z"), 117 | ) 118 | 119 | vehicle_data = await self.api_request(req_param) 120 | ret = self._get_vehicle_value( 121 | vehicle_data, ["data", "vehicle", "totalTripStatistics", "mileageInKm"] 122 | ) 123 | if ret is not None: 124 | ret = round(ret, 1) 125 | 126 | value = self._get_vehicle_value( 127 | vehicle_data, 128 | ["data", "vehicle", "totalTripStatistics", "driveDurationInMinutes"], 129 | ) 130 | if value is not None: 131 | value = round(value) 132 | att["Duration in minutes"] = value 133 | 134 | att["Trips"] = self._get_vehicle_value( 135 | vehicle_data, ["data", "vehicle", "totalTripStatistics", "numberTrips"] 136 | ) 137 | 138 | value = self._get_vehicle_value( 139 | vehicle_data, 140 | ["data", "vehicle", "totalTripStatistics", "longestMileageInKm"], 141 | ) 142 | if value is not None: 143 | value = round(value, 1) 144 | att["Longest trip in km"] = value 145 | 146 | return ret, att 147 | 148 | # async def get_mileage_since_refuel(self, vehicle_id): 149 | # """Calculate distance since last refuel event.""" 150 | # _LOGGER.warning("get_mileage_since_refuel...") 151 | # ret = None 152 | # att = dict() 153 | 154 | # req_param = """query fuel { 155 | # vehicle(id: %s) { 156 | # refuelEvents(limit: 1) { 157 | # time 158 | # } 159 | # serverCalcGpsOdometers(limit: 1, order: DESC){odometer time} 160 | # } 161 | # } 162 | # """ 163 | # req_param = req_param % (vehicle_id) 164 | 165 | # vehicle_data = await self.api_request(req_param) 166 | 167 | # fuelevent = self._get_vehicle_value( 168 | # vehicle_data, ["data", "vehicle", "refuelEvents"] 169 | # ) 170 | # odometers = self._get_vehicle_value( 171 | # vehicle_data, ["data", "vehicle", "serverCalcGpsOdometers"] 172 | # ) 173 | 174 | # if ( 175 | # fuelevent is not None 176 | # and len(fuelevent) == 1 177 | # and fuelevent[0]["time"] is not None 178 | # and odometers is not None 179 | # and len(odometers) == 1 180 | # and odometers[0]["odometer"] is not None 181 | # ): 182 | 183 | # odometer_current = odometers[0]["odometer"] 184 | # fuel_time = fuelevent[0]["time"] 185 | # att["Refueled at"] = fuel_time 186 | 187 | # odometer_fuel_time = await self.get_odometer_at_time(vehicle_id, fuel_time) 188 | # if odometer_fuel_time is not None: 189 | # ret = odometer_current - odometer_fuel_time 190 | # else: 191 | # ret = 0 192 | 193 | # return ret, att 194 | 195 | async def get_trip_at_time(self, vehicle_id, isotime): 196 | """Get trip at a specific time.""" 197 | trip = None 198 | 199 | req_param = """query fuel { 200 | vehicle(id: %s) { 201 | trips(fromTime: "%s", first: 1 ){items{mileage, gpsMileage, odometerMileage, startOdometer, endOdometer, startTime, endTime, time}} 202 | }} 203 | """ 204 | req_param = req_param % (vehicle_id, isotime) 205 | # _LOGGER.warning("req_param: %s", req_param) 206 | 207 | vehicle_data = await self.api_request(req_param) 208 | # _LOGGER.warning("vehicle_data: %s", vehicle_data) 209 | 210 | trip = self._get_vehicle_value( 211 | vehicle_data, ["data", "vehicle", "trips", "items", 0] 212 | ) 213 | 214 | return trip 215 | 216 | # async def get_odometer_at_time(self, vehicle_id, isotime): 217 | # """Get calculated odometer value at a specific time""" 218 | # odometer = None 219 | 220 | # req_param = """query fuel { 221 | # vehicle(id: %s) { 222 | # serverCalcGpsOdometers(first: "%s", limit: 1, order: ASC){odometer time} 223 | # } 224 | # } 225 | # """ 226 | # req_param = req_param % (vehicle_id, isotime) 227 | # # _LOGGER.warning("req_param: %s", req_param) 228 | 229 | # vehicle_data = await self.api_request(req_param) 230 | # # _LOGGER.warning("vehicle_data: %s", vehicle_data) 231 | 232 | # odometers = self._get_vehicle_value( 233 | # vehicle_data, ["data", "vehicle", "serverCalcGpsOdometers"] 234 | # ) 235 | 236 | # if ( 237 | # odometers is not None 238 | # and len(odometers) == 1 239 | # and odometers[0]["odometer"] is not None 240 | # ): 241 | # odometer = odometers[0]["odometer"] 242 | 243 | # return odometer 244 | 245 | # async def get_fuel_economy(self, vehicle_id): 246 | # """Calculate distance driven per liter of fuel.""" 247 | # ret = None 248 | # att = dict() 249 | 250 | # req_param = """query fuel { 251 | # vehicle(id: %s) { 252 | # refuelEvents(limit: 2, order: DESC) { 253 | # litersAfter 254 | # litersBefore 255 | # time 256 | # } 257 | # fuelLevel { 258 | # time 259 | # liter 260 | # } 261 | # } 262 | # } 263 | # """ 264 | # req_param = req_param % (vehicle_id) 265 | 266 | # vehicle_data = await self.api_request(req_param) 267 | 268 | # fuelevents = self._get_vehicle_value( 269 | # vehicle_data, ["data", "vehicle", "refuelEvents"] 270 | # ) 271 | # fuellevel = self._get_vehicle_value( 272 | # vehicle_data, ["data", "vehicle", "fuelLevel"] 273 | # ) 274 | 275 | # if fuelevents is not None and len(fuelevents) >= 2 and fuellevel is not None: 276 | 277 | # fuel_used = 0 278 | # fuel_used += fuelevents[1]["litersAfter"] - fuelevents[0]["litersBefore"] 279 | # fuel_used += fuelevents[0]["litersAfter"] - fuellevel["liter"] 280 | # att["Fuel used"] = fuel_used 281 | 282 | # # Request distance 283 | # time_start = fuelevents[1]["time"] 284 | # time_end = fuellevel["time"] 285 | 286 | # req_param = """query fuelDistance { 287 | # vehicle(id: %s) { 288 | # totalTripStatistics(period: {first: "%s", last: "%s"} ) { mileageInKm } 289 | # } 290 | # } 291 | # """ 292 | # req_param = req_param % ( 293 | # vehicle_id, 294 | # time_start, 295 | # time_end, 296 | # ) 297 | 298 | # vehicle_data = await self.api_request(req_param) 299 | # distance = self._get_vehicle_value( 300 | # vehicle_data, ["data", "vehicle", "totalTripStatistics", "mileageInKm"] 301 | # ) 302 | 303 | # if distance is not None: 304 | # att["distance"] = distance 305 | # ret = round(distance / fuel_used, 1) 306 | 307 | # return ret, att 308 | 309 | def has_value(self, obj, key) -> bool: 310 | """Check if object has key.""" 311 | return key in obj and obj[key] is not None 312 | 313 | def obj_copy_attributes(self, obj_src, obj_dst, keys): 314 | """Copy attributes.""" 315 | if obj_src is not None and obj_dst is not None: 316 | for key in keys: 317 | if self.has_value(obj_src, key): 318 | # obj_dst[keys[key]] = obj_src[key] 319 | obj_dst[key] = obj_src[key] 320 | return obj_dst 321 | 322 | async def get_leads(self, vehicle_id): 323 | """Find vehicle.""" 324 | ret = [] 325 | data = await self._get_vehicle_data() 326 | for item in data["data"]["viewer"]["vehicles"]: 327 | vehicle = item["vehicle"] 328 | if vehicle["id"] == vehicle_id: 329 | # j = 0 330 | for lead in vehicle["leads"]: 331 | try: 332 | # Basic info 333 | element = { 334 | "type": lead["type"], 335 | "createdTime": lead["createdTime"], 336 | } 337 | # Optional info 338 | element = self.obj_copy_attributes( 339 | lead, 340 | element, 341 | [ 342 | "updatedTime", 343 | "bookingTime", 344 | "lastContactedTime", 345 | "severityScore", 346 | ], 347 | ) 348 | # Value 349 | if self.has_value(lead, "value"): 350 | element["value"] = ( 351 | f"{lead['value']['amount']} {lead['value']['currency']}" 352 | ) 353 | 354 | # Context - Type specific info 355 | if self.has_value(lead, "context"): 356 | # Type: service_reminder 357 | if lead["type"] == "service_reminder": 358 | element["context"] = self.obj_copy_attributes( 359 | lead["context"], 360 | {}, 361 | ["serviceDate", "oilEstimateUncertain"], 362 | ) 363 | if lead["context"]["sourceData"] is not None: 364 | for data in lead["context"]["sourceData"]: 365 | if ( 366 | data is not None 367 | and data["type"] is not None 368 | and data["value"] is not None 369 | ): 370 | element["context"][data["type"]] = data[ 371 | "value" 372 | ] 373 | else: 374 | if self.has_value(lead, "context"): 375 | element["context"] = lead["context"] 376 | 377 | # Remove emply values in context 378 | remove_keys = [] 379 | if element["context"] is not None: 380 | for key in element["context"]: 381 | if element["context"][key] is None: 382 | _LOGGER.debug("Key to remove: %s", key) 383 | remove_keys.append(key) 384 | for key in remove_keys: 385 | element["context"].pop(key) 386 | 387 | ret.append(element) 388 | 389 | # j = j + 1 390 | # if j >= 5: 391 | # break 392 | 393 | except Exception as err: # pylint: disable=broad-except 394 | _LOGGER.error("Failed to handle lead: %s\n%s", lead, err) 395 | 396 | return ret 397 | 398 | async def get_value_float(self, vehicle_id, selector): 399 | """Extract a float value from read data.""" 400 | ret = None 401 | data = await self.get_value(vehicle_id, selector) 402 | if isinstance(data, str): # type(data) == str 403 | ret = float(data) 404 | if isinstance(data, (float, int)): 405 | ret = data 406 | return ret 407 | 408 | async def get_value(self, vehicle_id, selector): 409 | """Find vehicle.""" 410 | ret = None 411 | data = await self._get_vehicle_data() 412 | # vehicles = [] 413 | for item in data["data"]["viewer"]["vehicles"]: 414 | vehicle = item["vehicle"] 415 | if vehicle["id"] == vehicle_id: 416 | ret = self._get_vehicle_value(vehicle, selector) 417 | return ret 418 | 419 | def _get_vehicle_value(self, vehicle, selector): 420 | """Get selected attribures in vehicle data.""" 421 | obj = vehicle 422 | for sel in selector: 423 | if (obj is not None) and ( 424 | sel in obj or (isinstance(obj, list) and sel < len(obj)) 425 | ): 426 | # print(obj) 427 | # print(sel) 428 | obj = obj[sel] 429 | else: 430 | # Object does not have specified selector(s) 431 | obj = None 432 | break 433 | return obj 434 | 435 | async def get_lampstatus(self, vehicle_id, lamptype) -> tuple[str, str]: 436 | """Get status of warning lamps.""" 437 | ret = None 438 | time = None 439 | data = await self._get_vehicle_data() 440 | # vehicles = [] 441 | for item in data["data"]["viewer"]["vehicles"]: 442 | vehicle = item["vehicle"] 443 | if vehicle["id"] == vehicle_id: 444 | obj = vehicle 445 | for lamp in obj["lampStates"]: 446 | # print(lamp) 447 | if lamp["type"] == lamptype: 448 | ret = lamp["enabled"] 449 | time = lamp["time"] 450 | break 451 | return ret, time 452 | 453 | async def _get_voltage(self, vehicle_id): 454 | ret = None 455 | data = await self._get_vehicle_data() 456 | for item in data["data"]["viewer"]["vehicles"]: 457 | vehicle = item["vehicle"] 458 | if vehicle["id"] == vehicle_id: 459 | ret = vehicle["latestBatteryVoltage"]["voltage"] 460 | return ret 461 | 462 | async def get_vehicle_instances(self, include_additional_parameters=False): 463 | """Get vehicle instances and sensor data available.""" 464 | data = await self._get_vehicle_data() 465 | vehicles = [] 466 | for item in data["data"]["viewer"]["vehicles"]: 467 | vehicle = item["vehicle"] 468 | vehicle_id = vehicle["id"] 469 | 470 | # Find lamps for this vehicle 471 | lampstates = [lamp["type"] for lamp in vehicle["lampStates"]] 472 | # for lamp in vehicle["lampStates"]: 473 | # lampstates.append(lamp["type"]) 474 | 475 | # Find data availability for sensors 476 | has = [] 477 | if ( 478 | self._get_vehicle_value(vehicle, ["outdoorTemperatures", 0, "celsius"]) 479 | is not None 480 | ): 481 | has.append("outdoorTemperature") 482 | if ( 483 | self._get_vehicle_value(vehicle, ["latestBatteryVoltage", "voltage"]) 484 | is not None 485 | ): 486 | has.append("BatteryVoltage") 487 | if ( 488 | self._get_vehicle_value(vehicle, ["fuelPercentage", "percent"]) 489 | is not None 490 | ): 491 | has.append("fuelPercentage") 492 | if self._get_vehicle_value(vehicle, ["fuelLevel", "liter"]) is not None: 493 | has.append("fuelLevel") 494 | if self._get_vehicle_value(vehicle, ["fuelEconomy"]) is not None: 495 | has.append("fuelEconomy") 496 | if self._get_vehicle_value(vehicle, ["odometer", "odometer"]) is not None: 497 | has.append("odometer") 498 | if await self.get_next_service_data_predicted(vehicle_id) is not None: 499 | has.append("NextServicePredicted") 500 | if ( 501 | self._get_vehicle_value(vehicle, ["chargePercentage", "pct"]) 502 | is not None 503 | ): 504 | has.append("EVchargePercentage") 505 | if ( 506 | self._get_vehicle_value( 507 | vehicle, ["highVoltageBatteryTemperature", "celsius"] 508 | ) 509 | is not None 510 | ): 511 | has.append("EVHVBattTemp") 512 | if self._get_vehicle_value(vehicle, ["rangeTotalKm", "km"]) is not None: 513 | has.append("RangeTotal") 514 | 515 | if self._get_vehicle_value(vehicle, ["ignition", "on"]) is not None: 516 | has.append("Ignition") 517 | if self._get_vehicle_value(vehicle, ["health", "ok"]) is not None: 518 | has.append("Health") 519 | 520 | if ( 521 | self._get_vehicle_value(vehicle, ["position", "latitude"]) is not None 522 | and self._get_vehicle_value(vehicle, ["position", "longitude"]) 523 | is not None 524 | ): 525 | has.append("GeoLocation") 526 | 527 | if self._get_vehicle_value(vehicle, ["position", "speed"]) is not None: 528 | has.append("Speed") 529 | 530 | if ( 531 | self._get_vehicle_value(vehicle, ["refuelEvents", 0, "time"]) 532 | is not None 533 | ): 534 | has.append("refuelEvents") 535 | 536 | # Request additional parameters 537 | if include_additional_parameters: 538 | req_param = """query AdditionalParameters { 539 | vehicle(id: %s) { 540 | totalTripStatistics(period: {first: "%s", last: "%s"}) {mileageInKm, driveDurationInMinutes, numberTrips, longestMileageInKm} 541 | serverCalcGpsOdometers(limit: 1, order: DESC){odometer, time} 542 | trips(last: 1){items{mileage, gpsMileage, odometerMileage, startOdometer, endOdometer, startTime, endTime, time}} 543 | }} 544 | """ 545 | # refuelEvents(limit: 1) {time, litersAfter, litersBefore} 546 | 547 | date = datetime.now(UTC) # datetime.utcnow() 548 | 549 | req_param = req_param % ( 550 | vehicle_id, 551 | (date + relativedelta(months=-2)) 552 | .isoformat(timespec="milliseconds") 553 | .replace("+00:00", "Z"), 554 | date.isoformat(timespec="milliseconds").replace("+00:00", "Z"), 555 | ) 556 | 557 | vehicle_data = await self.api_request(req_param) 558 | if ( 559 | self._get_vehicle_value( 560 | vehicle_data, 561 | ["data", "vehicle", "totalTripStatistics", "mileageInKm"], 562 | ) 563 | is not None 564 | ): 565 | has.append("totalTripStatistics") 566 | 567 | if ( 568 | self._get_vehicle_value( 569 | vehicle_data, 570 | ["data", "vehicle", "serverCalcGpsOdometers", 0, "odometer"], 571 | ) 572 | is not None 573 | ): 574 | has.append("serverCalcGpsOdometers") 575 | 576 | if ( 577 | self._get_vehicle_value( 578 | vehicle_data, ["data", "vehicle", "trips", "items", 0, "time"] 579 | ) 580 | is not None 581 | ): 582 | has.append("trips") 583 | 584 | # Add vehicle to array 585 | vehicles.append( 586 | { 587 | "id": vehicle_id, 588 | "vin": vehicle["vin"], 589 | "name": vehicle["name"], 590 | "make": vehicle["make"], 591 | "model": vehicle["model"], 592 | "licensePlate": vehicle["licensePlate"], 593 | "lampStates": lampstates, 594 | "has": has, 595 | } 596 | ) 597 | 598 | return vehicles 599 | 600 | async def _get_vehicle_data(self): 601 | """Read data from API.""" 602 | 603 | async with self._lock_update: 604 | if ( 605 | self._data_expires is None 606 | or self._data is None 607 | or datetime.now(UTC) > self._data_expires 608 | ): 609 | self._data_expires = None 610 | self._data = None 611 | 612 | req_param = """query User { 613 | viewer { 614 | vehicles { 615 | primary 616 | vehicle { 617 | odometer { 618 | odometer 619 | time 620 | } 621 | odometerOffset 622 | id 623 | vin 624 | licensePlate 625 | name 626 | brand 627 | make 628 | model 629 | year 630 | engineSize 631 | avgCO2EmissionKm 632 | fuelEconomy 633 | fuelType 634 | fuelLevel { 635 | time 636 | liter 637 | } 638 | refuelEvents(limit: 1) { 639 | litersAfter 640 | time 641 | } 642 | fuelTankSize(limit: 1) 643 | fuelPercentage { 644 | percent 645 | time 646 | } 647 | adblueRemainingKm(limit: 1) { 648 | km 649 | } 650 | chargePercentage { 651 | pct 652 | time 653 | } 654 | highVoltageBatteryTemperature { 655 | celsius 656 | time 657 | } 658 | rangeTotalKm { km time } 659 | ignition { 660 | time 661 | on 662 | } 663 | lampStates { 664 | type 665 | time 666 | enabled 667 | lampDetails { 668 | title 669 | subtitle 670 | } 671 | } 672 | outdoorTemperatures(limit: 1) { 673 | celsius 674 | time 675 | } 676 | position { 677 | latitude 678 | longitude 679 | speed 680 | direction 681 | time 682 | } 683 | service { 684 | predictedDate 685 | } 686 | latestBatteryVoltage { 687 | voltage 688 | time 689 | } 690 | health { ok } 691 | leads(statuses: [open], orderBy: {field: created_at, direction: DESC}) { 692 | type 693 | status 694 | interactions{time, channel} 695 | severityScore 696 | value{amount, currency} 697 | createdTime 698 | updatedTime 699 | lastActivityTime 700 | bookingTime 701 | lastContactedTime 702 | context { 703 | ... on LeadErrorCodeContext { 704 | errorCode, ecu, provider, errorCodeCount, description, severity, firstErrorCodeTime, lastErrorCodeTime 705 | } 706 | ... on LeadLowBatteryVoltageContext { 707 | sourceMedianVoltage { voltage } 708 | } 709 | ... on LeadServiceReminderContext { 710 | serviceDate, oilEstimateUncertain, sourceData { type, value } 711 | } 712 | ... on LeadConnectivityIssueContext{ 713 | latestVehiclePositionRecordTime 714 | } 715 | ... on LeadEngineLampContext{ 716 | lamps { type, color, title, subtitle, recommendationText, descriptionTitle, descriptionText } 717 | } 718 | ... on LeadMainPowerDisconnectContext{ 719 | disconnectionEventTime, disconnectionLatitude, disconnectionLongitude, disconnectionPositionTime, unitConnectionState, lastConnectionEventTime, incidentCount 720 | } 721 | ... on LeadDefaultContext{ 722 | context 723 | } 724 | ... on LeadRapidBatteryDischargeContext{ 725 | time, durationHours, minVoltage, maxVoltage, voltageDrop 726 | } 727 | ... on LeadQuoteContext{ 728 | quote{workshop{name},title,price{amount, currency},expirationDate,status} 729 | } 730 | ... on UserReportedLampLeadContext{ 731 | type, color, frequency, source 732 | } 733 | } 734 | } 735 | 736 | } 737 | } 738 | } 739 | } 740 | """ 741 | req_body = {"query": req_param} 742 | 743 | headers = { 744 | "Content-Type": "application/json", 745 | "Accept": "application/json", 746 | "x-organization-namespace": f"semler:{self._namespace}", 747 | "User-Agent": "ConnectedCars/360 CFNetwork/978.0.7 Darwin/18.7.0", 748 | "Authorization": f"Bearer {await self._get_access_token()}", 749 | } 750 | 751 | req_url = self._base_url_graph + "graphql" 752 | 753 | # async with aiohttp.ClientSession() as session: 754 | # async with session.post( 755 | # req_url, json=req_body, headers=headers 756 | # ) as response: 757 | async with ( 758 | aiohttp.ClientSession() as session, 759 | session.post(req_url, json=req_body, headers=headers) as response, 760 | ): 761 | self._data = await response.json() 762 | # self._data = json.loads('') 763 | _LOGGER.debug("Got vehicle data: %s", json.dumps(self._data)) 764 | 765 | # Does any car have ignition? 766 | expire_time = 4.75 767 | for item in self._data["data"]["viewer"]["vehicles"]: 768 | vehicle = item["vehicle"] 769 | # id = vehicle["id"] 770 | ignition = self._get_vehicle_value( 771 | vehicle, ["ignition", "on"] 772 | ) # Preferred to check this only, but it seems to be delayed 773 | speed = self._get_vehicle_value(vehicle, ["position", "speed"]) 774 | speed = speed if speed is not None else 0 775 | if bool(ignition) is True or speed > 0: # ignition == True 776 | expire_time = 0.75 # At least one car has ignition/moving 777 | break 778 | self._data_expires = datetime.now(UTC) + timedelta( 779 | minutes=expire_time 780 | ) 781 | 782 | # result = requests.post(req_url, json = req_body, headers = headers) 783 | # print(result) 784 | # result_json = result.json() 785 | # print(result_json['data']['viewer']['vehicles']) 786 | 787 | # for item in self._data['data']['viewer']['vehicles']: 788 | # vehicle = item['vehicle'] 789 | # print(f"Primary: {item['primary']}") 790 | # print(f"Name: {vehicle['name']}") 791 | # print(f"LicPlat: {vehicle['licensePlate']}") 792 | 793 | return self._data 794 | 795 | async def _get_access_token(self): 796 | """Authenticate to get access token.""" 797 | 798 | if ( 799 | self._accesstoken is None 800 | or self._at_expires is None 801 | or datetime.now(UTC) > self._at_expires 802 | ): 803 | headers = { 804 | "Content-Type": "application/json", 805 | "Accept": "application/json", 806 | "x-organization-namespace": f"semler:{self._namespace}", 807 | "User-Agent": "ConnectedCars/360 CFNetwork/978.0.7 Darwin/18.7.0", 808 | } 809 | body = {"email": self._email, "password": self._password} 810 | 811 | # Authenticate 812 | try: 813 | self._accesstoken = None 814 | self._at_expires = None 815 | result_json = None 816 | 817 | _LOGGER.debug("Getting access token...") 818 | 819 | auth_url = self._base_url_auth + "auth/login/email/password" 820 | 821 | # async with aiohttp.ClientSession() as session: 822 | # async with session.post( 823 | # auth_url, json=body, headers=headers 824 | # ) as response: 825 | # result_json = await response.json() 826 | async with ( 827 | aiohttp.ClientSession() as session, 828 | session.post(auth_url, json=body, headers=headers) as response, 829 | ): 830 | result_json = await response.json() 831 | 832 | # result = await requests.post(auth_url, json = body, headers = headers) 833 | # result_json = result.json() 834 | # print(result_json) 835 | 836 | if ( 837 | result_json is not None 838 | and "token" in result_json 839 | and "expires" in result_json 840 | ): 841 | self._accesstoken = result_json["token"] 842 | self._at_expires = datetime.now(UTC) + timedelta( 843 | seconds=int(result_json["expires"]) - 120 844 | ) 845 | _LOGGER.debug("Got access token: %s...", self._accesstoken[:10]) 846 | if ( 847 | result_json is not None 848 | and "error" in result_json 849 | and "message" in result_json 850 | ): 851 | raise Exception(result_json["message"]) 852 | 853 | except aiohttp.ClientError as client_error: 854 | _LOGGER.warning("Authentication failed. %s", client_error) 855 | # except requests.exceptions.Timeout: 856 | # _LOGGER.warn("Authentication failed. Timeout") 857 | # except requests.exceptions.HTTPError as e: 858 | # _LOGGER.warn(f"Authentication failed. HTTP error: {e}.") 859 | # except requests.exceptions.RequestException as e: 860 | # _LOGGER.warn(f"Authentication failed: {e}.") 861 | 862 | # print(self._at_expires) 863 | 864 | return self._accesstoken 865 | --------------------------------------------------------------------------------