pax_global_header00006660000000000000000000000064144332211130014504gustar00rootroot0000000000000052 comment=9b34ec3636d9a378f67e0cf8f40c0f3ff9e59208 molobrakos-volvooncall-8b82c97/000077500000000000000000000000001443322111300165605ustar00rootroot00000000000000molobrakos-volvooncall-8b82c97/.github/000077500000000000000000000000001443322111300201205ustar00rootroot00000000000000molobrakos-volvooncall-8b82c97/.github/workflows/000077500000000000000000000000001443322111300221555ustar00rootroot00000000000000molobrakos-volvooncall-8b82c97/.github/workflows/ci.yml000066400000000000000000000010621443322111300232720ustar00rootroot00000000000000name: CI on: push: branches: - master pull_request: branches: - master jobs: CI: runs-on: ubuntu-latest strategy: matrix: include: - python-version: '3.10' toxenv: py310,lint,pytype steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: pip install tox - name: tox run: tox env: TOXENV: ${{ matrix.toxenv }} molobrakos-volvooncall-8b82c97/.gitignore000066400000000000000000000001671443322111300205540ustar00rootroot00000000000000*~ .voc.conf *.egg-info *.pyc MANIFEST .cache .tox dist bin include lib pip-selfcheck.json .pytest_cache pytype_output molobrakos-volvooncall-8b82c97/.python-version000066400000000000000000000000051443322111300215600ustar00rootroot000000000000003.10 molobrakos-volvooncall-8b82c97/.voc.template.conf000066400000000000000000000000771443322111300221120ustar00rootroot00000000000000username: password: # service_url: molobrakos-volvooncall-8b82c97/Dockerfile000066400000000000000000000010351443322111300205510ustar00rootroot00000000000000FROM python:3.10-slim-bullseye ADD . /app WORKDIR /app RUN set -x \ && apt-get update \ && apt-get -y --no-install-recommends install dumb-init libsodium18 \ && apt-get -y autoremove \ && apt-get -y clean \ && rm -rf /var/lib/apt/lists/* \ && rm -rf /tmp/* \ && rm -rf /var/tmp/* \ && useradd -M --home-dir /app voc \ ; RUN pip --no-cache-dir --trusted-host pypi.org install --upgrade -r /app/requirements.txt pip coloredlogs libnacl \ && pip install /app && rm -rf /app \ ; USER voc ENTRYPOINT ["dumb-init", "--", "voc", "mqtt"] molobrakos-volvooncall-8b82c97/LICENSE000066400000000000000000000022721443322111300175700ustar00rootroot00000000000000This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to molobrakos-volvooncall-8b82c97/MANIFEST.in000066400000000000000000000000201443322111300203060ustar00rootroot00000000000000include LICENSE molobrakos-volvooncall-8b82c97/Makefile000066400000000000000000000023621443322111300202230ustar00rootroot00000000000000.PHONY: default format white black lint test check clean pypireg pypi release docker-build docker-run-mqtt docker-run-mqtt-term default: check format: white white: black black: white . voc lint: requirements.txt setup.py tox -e lint tox -e pytype test: requirements.txt setup.py tox check: lint test clean: rm -f *.pyc rm -rf .tox rm -rf *.egg-info rm -rf __pycache__ rm -f pip-selfcheck.json rm -rf pytype_output pypireg: python setup.py register -r pypi pypi: rm -f dist/*.tar.gz python3 setup.py sdist twine upload dist/*.tar.gz release: git diff-index --quiet HEAD -- && make check && bumpversion patch && git push --tags && git push && make pypi IMAGE=molobrakos/volvooncall docker-build: docker build -t $(IMAGE) . docker-run-mqtt: docker run \ --name=volvooncall \ --restart=always \ --detach \ --net=bridge \ -v $(HOME)/.config/voc.conf:/app/.config/voc.conf:ro \ -v $(HOME)/.config/mosquitto_pub:/app/.config/mosquitto_pub:ro \ $(IMAGE) -vv docker-run-mqtt-term: docker run \ -ti --rm \ --name=volvooncall \ --net=bridge \ -v $(HOME)/.config/voc.conf:/app/.config/voc.conf:ro \ -v $(HOME)/.config/mosquitto_pub:/app/.config/mosquitto_pub:ro \ $(IMAGE) -vv molobrakos-volvooncall-8b82c97/README.md000066400000000000000000000056321443322111300200450ustar00rootroot00000000000000# Volvo On Call [![CI](https://github.com/molobrakos/volvooncall/actions/workflows/ci.yml/badge.svg)](https://github.com/molobrakos/volvooncall/actions/workflows/ci.yml) Retrieve statistics about your Volvo from the Volvo On Call (VOC) online service No licence, public domain, no guarantees, feel free to use for anything. Please contribute improvements/bugfixes etc. Also contains an MQTT gateway for publishing information and bidirectional communication with e.g. Home Assistant. ## system requirements - At least python 3.10 or higher > For contributors: The `pytype` project does not yet support Python 3.11, so you must use 3.10 to run tests locally. ## dependencies To use just the API in `volvooncall.py` or the Home Assistant bindings in `dashboard.py`, simply install the package as usual with pip: ```sh pip install volvooncall ``` To use console features (i.e. the `voc` command documented below): ```sh pip install volvooncall[console] ``` To use MQTT features: ```sh pip install volvooncall[mqtt] ``` ## how to use ``` > voc --help Retrieve information from VOC Usage: voc (-h | --help) voc --version voc [-v|-vv] [options] list voc [-v|-vv] [options] status voc [-v|-vv] [options] trips voc [-v|-vv] [options] owntracks voc [-v|-vv] [options] print [] voc [-v|-vv] [options] (lock | unlock) voc [-v|-vv] [options] heater (start | stop) voc [-v|-vv] [options] engine (start | stop) voc [-v|-vv] [options] call voc [-v|-vv] [options] mqtt Options: -u VOC username -p VOC password -r VOC region (na, cn, etc.) -s VOC service URL -i Vehicle VIN or registration number -g Geolocate position --owntracks_key= Owntracks encryption password -I Polling interval (seconds) [default: 300] -h --help Show this message -v,-vv Increase verbosity --scandinavian_miles Report using Scandinavian miles instead of km ISO unit --usa_units Report using USA units (miles, mph, mpg, gal, etc.) --version Show version ``` Retrieving basic status: ``` > voc status ABC123 (XC60/2014) ABCD1234567890 92891km (fuel 25% 210km) position: 12.34567890,12.34567890 locked: yes heater: off ``` Printing raw properties: ``` > voc print windows.frontLeftWindowOpen False ./voc print fuelAmount 45 ``` Printing some relevant iofo: ``` > voc dashboard ABC123 Door lock: Locked ABC123 Heater: Off ABC123 Odometer: 12792 mil ABC123 Fuel amount: 32 L ... ``` Periodically polling the VOC server and republishing all information to a MQTT server ``` > voc mqtt ``` Configuration file in `$HOME/.voc.conf`: ``` username: password: ``` # credits https://web.archive.org/web/20180817103553/https://paulpeelen.com/2013/02/08/volvo-on-call-voc-api/ and a lot of random contributors molobrakos-volvooncall-8b82c97/pylintrc000066400000000000000000000001121443322111300203410ustar00rootroot00000000000000[REPORTS] reports=no [MESSAGES CONTROL] # disable= # locally-disabled, molobrakos-volvooncall-8b82c97/requirements.txt000066400000000000000000000000771443322111300220500ustar00rootroot00000000000000aiohttp<4.0 amqtt>=0.10.0,<0.11.0 certifi geopy>=1.14.0 docopt molobrakos-volvooncall-8b82c97/setup.cfg000066400000000000000000000013561443322111300204060ustar00rootroot00000000000000[bumpversion] current_version = 0.10.4 commit = True tag = True files = volvooncall/volvooncall.py [metadata] name = volvooncall version = 0.10.3 description = Communicate with VOC url = https://github.com/molobrakos/volvooncall license = Unlicense author = Erik author_email = error.errorsson@gmail.com maintainer = decompil3d [options] scripts = voc packages = volvooncall install_requires = aiohttp<4.0 python_requires = >=3.10 [options.extras_require] console = certifi docopt geopy>=1.14.0 mqtt = amqtt>=0.10.0,<0.11.0 certifi [flake8] exclude = .venv,.git,.tox,include,lib,bin,.tox,.tmp import-order-style = pep8 [pytype] inputs = voc volvooncall python_version = 3.10 disable = module-attr, attribute-error, import-error molobrakos-volvooncall-8b82c97/setup.py000066400000000000000000000000451443322111300202710ustar00rootroot00000000000000from setuptools import setup setup() molobrakos-volvooncall-8b82c97/test_main.py000066400000000000000000000057451443322111300211300ustar00rootroot00000000000000import asyncio from asynctest import patch import pytest from volvooncall import Connection def mocked_request(method, url, rel=None, **kwargs): if "customeraccounts" in url: return {"username": "foobar", "accountVehicleRelations": ["rel/1"]} if "rel" in url: return {"vehicle": "vehicle/1", "status": "Verified"} if "attributes" in url: return {"registrationNumber": "FOO123"} if "status" in url: return { "engineRunning": False, "engineStartSupported": True, "ERS": {"status": "off"}, } if "position" in url: return {} if "engine/start" in url: return {"service": "engine/start", "status": "Started"} return {"error": "Unauthorized"} @patch("volvooncall.Connection._request", side_effect=mocked_request) async def get_vehicle(mock): connection = Connection(session=None, username="", password="") await connection.update() assert mock.called return next(connection.vehicles, None) @pytest.mark.asyncio async def test_basic(): vehicle = await get_vehicle() assert vehicle is not None assert vehicle.registration_number == "FOO123" @pytest.mark.asyncio async def test_engine(): vehicle = await get_vehicle() assert not vehicle.is_engine_running @pytest.mark.asyncio async def test_ers(event_loop): vehicle = await get_vehicle() dashboard = vehicle.dashboard() engine_instruments = [ instrument for instrument in dashboard.instruments if instrument.attr == "is_engine_running" ] # a binary sensor and a switch should be present assert len(engine_instruments) == 2 # should be off assert all(not engine.state for engine in engine_instruments) async def get_started_vehicle(): def mocked_request_ers(method, url, rel=None, **kwargs): if "status" in url: return { "engineRunning": False, "engineStartSupported": True, "ERS": {"status": "on"}, } return mocked_request(method, url, rel, **kwargs) vehicle = await get_vehicle() with patch( "volvooncall.Connection._request", side_effect=mocked_request_ers ) as mock: await vehicle.start_engine() assert mock.called return vehicle @pytest.mark.asyncio async def test_ers_start(): vehicle = await get_started_vehicle() assert vehicle.is_engine_running @pytest.mark.asyncio async def test_ers_start_dashboard(): vehicle = await get_started_vehicle() dashboard = vehicle.dashboard() engine_instruments = [ instrument for instrument in dashboard.instruments if instrument.attr == "is_engine_running" ] # a binary sensor and a switch should be present assert len(engine_instruments) == 2 # shold be on assert all(engine.state for engine in engine_instruments) if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(test_basic()) molobrakos-volvooncall-8b82c97/tox.ini000066400000000000000000000013511443322111300200730ustar00rootroot00000000000000[tox] envlist= py310 skip_missing_interpreters = true [testenv] deps = -rrequirements.txt pytest pytest-sugar asynctest pytest-asyncio commands = py.test [pytest] addopts= --doctest-modules filterwarnings = ignore:Using or importing the ABCs.*and in 3.8 it will stop working:DeprecationWarning [testenv:lint] deps = -rrequirements.txt pylint black white flake8 flake8-bugbear flake8-import-order docutils commands = white --check volvooncall voc pylint -E volvooncall flake8 --version flake8 # pydocstyle python setup.py check --metadata --strict [testenv:pytype] commands = pytype deps = {[testenv]deps} pytype>=2021 libnacl molobrakos-volvooncall-8b82c97/voc000077500000000000000000000221571443322111300173040ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- mode: python; coding: utf-8 -*- """ Retrieve information from VOC Usage: voc (-h | --help) voc --version voc [-v|-vv] [options] list voc [-v|-vv] [options] status voc [-v|-vv] [options] trips [(--pretty|--json|--csv)] voc [-v|-vv] [options] owntracks voc [-v|-vv] [options] print [] voc [-v|-vv] [options] (lock | unlock) voc [-v|-vv] [options] heater (start | stop) voc [-v|-vv] [options] engine (start | stop) voc [-v|-vv] [options] honk_and_blink voc [-v|-vv] [options] call voc [-v|-vv] [options] mqtt voc [-v|-vv] [options] dashboard Options: -u VOC username -p VOC password -r VOC region (na, cn, etc.) -s VOC service URL -i Vehicle VIN or registration number -g Geolocate position --owntracks_key= Owntracks encryption password -I Polling interval (seconds) [default: 300] -h --help Show this message --immutable Read only mode -v,-vv Increase verbosity -d More debugging --scandinavian_miles Report using Scandinavian miles instead of km ISO unit --usa_units Report using USA units (miles, mph, mpg, gal, etc.) --utc Print timestamps in UTC (+00:00) instead of local time --version Show version """ import docopt import logging import asyncio from time import time from json import dumps as to_json from sys import stderr from collections import OrderedDict from datetime import timezone import ssl import certifi from aiohttp import ClientSession, TCPConnector from volvooncall import __version__, Connection from volvooncall.util import read_config, json_serialize, owntracks_encrypt _LOGGER = logging.getLogger(__name__) LOGFMT = "%(asctime)s %(levelname)5s (%(threadName)s) [%(name)s] %(message)s" DATEFMT = "%y-%m-%d %H:%M.%S" SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) def lookup_position(lat, lon): try: import geopy.geocoders from geopy.geocoders import Nominatim geolocator = Nominatim( user_agent="volvooncall/%s" % __version__, timeout=10, ssl_context=SSL_CONTEXT, ) return geolocator.reverse((lat, lon)) except ImportError: _LOGGER.warning( "geopy or certifi not installed. position lookup not available" ) def print_vehicle(vehicle, geolocate=False): def yes_or_no(boolean): if boolean is None: return "unknown" return ("no", "yes")[boolean] def on_or_off(boolean): if boolean is None: return "unknown" return ("off", "on")[boolean] s = "%s %dkm" % (vehicle, vehicle.odometer / 1000) if vehicle.fuel_amount_level: s += " (fuel %s%% %skm)" % ( vehicle.fuel_amount_level, vehicle.distance_to_empty, ) print(s) try: lat, lon = ( vehicle.position["latitude"], vehicle.position["longitude"], ) pos = lookup_position(lat, lon) if geolocate else None if pos: print(" position: %.14f,%.14f (%s)" % (lat, lon, pos.address)) else: print(" position: %.14f,%.14f" % (lat, lon)) except AttributeError: pass print(" engine: %s" % on_or_off(vehicle.is_engine_running)) print(" locked: %s" % yes_or_no(vehicle.is_locked)) print(" heater: %s" % on_or_off(vehicle.is_heater_on)) async def main(args): """Command line interface.""" config = read_config() if args["-u"] and args["-p"]: config.update(username=args["-u"], password=args["-p"]) if args["-r"]: config.update(region=args["-r"]) if args["-s"]: config.update(service_url=args["-s"]) if args["-I"]: config.update(interval=args["-I"]) if args["--owntracks_key"]: config.update(owntracks_key=args["--owntracks_key"]) if args["--scandinavian_miles"] or "--scandinavian_miles" not in config: config.update(scandinavian_miles=args["--scandinavian_miles"]) if args["--usa_units"] or "--usa_units" not in config: config.update(usa_units=args["--usa_units"]) if args["--immutable"] or "--immutable" not in config: config.update(mutable=not args["--immutable"]) async with ClientSession( connector=TCPConnector(ssl=SSL_CONTEXT) ) as session: try: connection = Connection(session, **config) except TypeError as error: exit("Missing configuration: %s" % error) if args["mqtt"]: from volvooncall import mqtt return await mqtt.run(connection, config) journal = args["trips"] or args["dashboard"] or args["mqtt"] res = await connection.update(journal=journal) if not res: exit("Could not connect to the server.") if args["list"]: for vehicle in connection.vehicles: print(vehicle) exit() if args["-i"]: vehicle = connection.vehicle(args["-i"]) else: vehicle = next(connection.vehicles, None) if not vehicle: exit("Vehicle not found") if args["status"]: print_vehicle(vehicle, args["-g"]) elif args["trips"]: if args["--json"]: print(to_json(vehicle.trips, indent=4, default=json_serialize)) else: def fix_timezone(dt): return dt.astimezone( timezone.utc if args["--utc"] else None ) for trip in vehicle.trips: trip = trip["tripDetails"][0] print( "%.29s %25s %-10.10s %25s %10.10s %-10.10s" % ( fix_timezone(trip["startTime"]), trip["startPosition"]["streetAddress"], trip["startPosition"]["city"], fix_timezone(trip["endTime"]), trip["endPosition"]["streetAddress"], trip["endPosition"]["city"], ) ) elif args["print"]: attr = args[""] if attr: if not vehicle.has_attr(attr): exit("unknown attribute") print(vehicle.get_attr(attr)) else: print(vehicle.json) elif args["owntracks"]: msg = to_json( dict( _type="location", tid="volvo", t="p", lat=vehicle.position["latitude"], lon=vehicle.position["longitude"], acc=1, tst=int(time()), ), indent=4, default=json_serialize, ) key = config.get("owntracks_key") if key: msg = to_json( dict(_type="encrypted", data=owntracks_encrypt(msg, key)) ) print(msg) elif args["heater"]: if args["start"]: await vehicle.start_heater() else: await vehicle.stop_heater() elif args["engine"]: if args["start"]: await vehicle.start_engine() else: await vehicle.stop_engine() elif args["lock"]: await vehicle.lock() elif args["unlock"]: await vehicle.unlock() elif args["honk_and_blink"]: await vehicle.honk_and_blink() elif args["dashboard"]: dashboard = vehicle.dashboard(**config) for instrument in dashboard.instruments: print("%-30s: %s" % (instrument, instrument.str_state)) elif args["call"]: await vehicle.call(args[""]) if __name__ == "__main__": args = docopt.docopt(__doc__, version=__version__) debug = args["-d"] if debug: log_level = logging.DEBUG else: log_level = [logging.ERROR, logging.INFO, logging.DEBUG][args["-v"]] try: import coloredlogs coloredlogs.install( level=log_level, stream=stderr, datefmt=DATEFMT, fmt=LOGFMT ) except ImportError: _LOGGER.debug("no colored logs. pip install coloredlogs?") logging.basicConfig( level=log_level, stream=stderr, datefmt=DATEFMT, format=LOGFMT ) logging.captureWarnings(debug) if debug: _LOGGER.info("Debug is on") try: from asyncio import run except ImportError: # pre 3.7 def run(fut, debug=False): loop = asyncio.get_event_loop() loop.set_debug(debug) loop.run_until_complete(fut) loop.close() asyncio.create_task = ( lambda coro: asyncio.get_event_loop().create_task(coro) ) try: run(main(args), debug=debug) except KeyboardInterrupt: exit() molobrakos-volvooncall-8b82c97/volvooncall/000077500000000000000000000000001443322111300211165ustar00rootroot00000000000000molobrakos-volvooncall-8b82c97/volvooncall/__init__.py000066400000000000000000000004241443322111300232270ustar00rootroot00000000000000from sys import version_info from .volvooncall import Connection, __version__ # noqa: F401 from .dashboard import Dashboard # noqa: F401 MIN_PYTHON_VERSION = (3, 10, 0) _ = version_info >= MIN_PYTHON_VERSION or exit( "Python %d.%d.%d required" % MIN_PYTHON_VERSION ) molobrakos-volvooncall-8b82c97/volvooncall/dashboard.py000066400000000000000000000361671443322111300234340ustar00rootroot00000000000000# Utilities for integration with Home Assistant (directly or via MQTT) import logging from .util import camel2slug _LOGGER = logging.getLogger(__name__) # fixme: move (mapping to) hass component names to config file instead class Instrument: def __init__(self, component, attr, name, icon=None, slug_override=None): self.attr = attr self.component = component self.name = name self.vehicle = None self.icon = icon self.slug_override = slug_override def __repr__(self): return self.full_name def configurate(self, **args): pass @property def slug_attr(self): if self.slug_override is not None: return self.slug_override else: return camel2slug(self.attr.replace(".", "_")) def setup(self, vehicle, mutable=True, **config): self.vehicle = vehicle if not mutable and self.is_mutable: _LOGGER.info("Skipping %s because mutable", self) return False if not self.is_supported: _LOGGER.debug( "%s (%s:%s) is not supported", self, type(self).__name__, self.attr, ) return False _LOGGER.debug("%s is supported", self) self.configurate(**config) return True @property def vehicle_name(self): return self.vehicle.registration_number or self.vehicle.vin @property def full_name(self): return "%s %s" % (self.vehicle_name, self.name) @property def is_mutable(self): raise NotImplementedError("Must be set") @property def is_supported(self): supported = "is_" + self.attr + "_supported" if hasattr(self.vehicle, supported): return getattr(self.vehicle, supported) if hasattr(self.vehicle, self.attr): return True return self.vehicle.has_attr(self.attr) @property def str_state(self): return self.state @property def state(self): if hasattr(self.vehicle, self.attr): return getattr(self.vehicle, self.attr) return self.vehicle.get_attr(self.attr) @property def attributes(self): return {} class Sensor(Instrument): def __init__(self, attr, name, icon, unit): super().__init__(component="sensor", attr=attr, name=name, icon=icon) self.unit = unit def configurate(self, scandinavian_miles=False, usa_units=False, **config): if self.unit and scandinavian_miles: if "km" == self.unit: self.unit = "mil" elif self.unit and usa_units: if "km/h" in self.unit: self.unit = "mph" elif "km" in self.unit: self.unit = "mi" elif "L" in self.unit: self.unit = "gal" @property def is_mutable(self): return False @property def str_state(self): if self.unit: return "%s %s" % (self.state, self.unit) else: return "%s" % self.state @property def state(self): val = super().state if val and "mil" in self.unit: return val / 10 elif val and "mi" in self.unit and "min" not in self.unit: return round(val * 0.621371, 1) elif val and "gal" in self.unit: return round(val * 0.264172, 1) elif val and "mph" in self.unit: return round(val * 0.621371, 1) else: return val class FuelConsumption(Sensor): def __init__(self): super().__init__( attr="averageFuelConsumption", name="Fuel consumption", icon="mdi:gas-station", unit="L/100 km", ) def configurate(self, scandinavian_miles=False, usa_units=False, **config): if scandinavian_miles: self.unit = "L/mil" elif usa_units: self.unit = "mpg" @property def state(self): val = super().state decimals = 2 if "mil" in self.unit else 1 if val: if "mpg" in self.unit: return round(235.215 / (val / 10), decimals) return round(val / 10, decimals) class Odometer(Sensor): def __init__(self, attr="odometer", name="Odometer"): super().__init__( attr=attr, name=name, icon="mdi:speedometer", unit="km" ) @property def state(self): val = super().state if val: return int(round(val / 1000)) # m->km return 0 class JournalLastTrip(Sensor): def __init__(self): super().__init__( attr="trips", name="Last trip", unit=None, icon="mdi:book-open" ) self.device_class = "date" @property def is_supported(self): return self.vehicle.is_journal_supported @property def trip(self): if self.vehicle.trips: return self.vehicle.trips[0]["tripDetails"][0] @property def start_address(self): return "{}, {}".format( self.trip["startPosition"]["streetAddress"], self.trip["startPosition"]["city"], ) @property def end_address(self): return "{}, {}".format( self.trip["endPosition"]["streetAddress"], self.trip["endPosition"]["city"], ) @property def start_time(self): return self.trip["startTime"].astimezone(None) @property def end_time(self): return self.trip["endTime"].astimezone(None) @property def duration(self): return self.end_time - self.start_time @property def state(self): if self.trip: return self.end_time @property def attributes(self): if self.trip: return dict( start_address=self.start_address, start_time=str(self.start_time), end_address=self.end_address, end_time=str(self.end_time), duration=str(self.duration), ) class BinarySensor(Instrument): def __init__(self, attr, name, device_class, slug_override=None): super().__init__( component="binary_sensor", attr=attr, name=name, slug_override=slug_override, ) self.device_class = device_class @property def is_mutable(self): return False @property def str_state(self): if self.device_class in ["door", "window"]: return "Open" if self.state else "Closed" if self.device_class == "safety": return "Warning!" if self.state else "OK" if self.device_class == "plug": return "Plugged in" if self.state else "Plug removed" if self.device_class == "battery_charging": return "Charging" if self.state else "Not charging" if self.state is None: _LOGGER.error("Can not encode state %s:%s", self.attr, self.state) return "?" return "On" if self.state else "Off" @property def state(self): val = super().state if isinstance(val, (bool, list)): # for list (e.g. bulb_failures): # empty list (False) means no problem return bool(val) elif isinstance(val, str): return val != "Normal" return val @property def is_on(self): return self.state class BatteryChargeStatus(BinarySensor): def __init__(self): super().__init__( "hvBattery.hvBatteryChargeStatusDerived", "Battery charging", "battery_charging", ) @property def state(self): return super(BinarySensor, self).state.endswith("_Charging") class PluggedInStatus(BinarySensor): def __init__(self): super().__init__( "hvBattery.hvBatteryChargeStatusDerived", "Plug status", "plug", slug_override="plugged_in_status", ) @property def state(self): return super(BinarySensor, self).state.startswith("CablePluggedInCar_") class Lock(Instrument): def __init__(self): super().__init__(component="lock", attr="lock", name="Door lock") @property def is_mutable(self): return True @property def str_state(self): return "Locked" if self.state else "Unlocked" @property def state(self): return self.vehicle.is_locked @property def is_locked(self): return self.state async def lock(self): await self.vehicle.lock() async def unlock(self): await self.vehicle.unlock() class Switch(Instrument): def __init__(self, attr, name, icon): super().__init__(component="switch", attr=attr, name=name, icon=icon) @property def is_mutable(self): return True @property def str_state(self): return "On" if self.state else "Off" def is_on(self): return self.state def turn_on(self): pass def turn_off(self): pass class Heater(Switch): def __init__(self): super().__init__(attr="heater", name="Heater", icon="mdi:radiator") @property def state(self): return self.vehicle.is_heater_on async def turn_on(self): await self.vehicle.start_heater() async def turn_off(self): await self.vehicle.stop_heater() class EngineStart(Switch): def __init__(self): super().__init__( attr="is_engine_running", name="Engine", icon="mdi:engine" ) @property def is_supported(self): return self.vehicle.is_engine_start_supported async def turn_on(self): await self.vehicle.start_engine() async def turn_off(self): await self.vehicle.stop_engine() class Position(Instrument): def __init__(self): super().__init__( component="device_tracker", attr="position", name="Position" ) @property def is_mutable(self): return False @property def state(self): state = super().state or {} return ( state.get("latitude", "?"), state.get("longitude", "?"), state.get("timestamp", None), state.get("speed", None), state.get("heading", None), ) @property def str_state(self): state = super().state or {} ts = state.get("timestamp") return ( state.get("latitude", "?"), state.get("longitude", "?"), str(ts.astimezone(tz=None)) if ts else None, state.get("speed", None), state.get("heading", None), ) # FIXME: Maybe make this list configurable as external yaml def create_instruments(usa_units=False, **config): tyre = "tire" if usa_units else "tyre" return [ Position(), Lock(), Heater(), Odometer(), Odometer(attr="tripMeter1", name="Trip meter 1"), Odometer(attr="tripMeter2", name="Trip meter 2"), Sensor( attr="fuelAmount", name="Fuel amount", icon="mdi:gas-station", unit="L", ), Sensor( attr="fuelAmountLevel", name="Fuel level", icon="mdi:water-percent", unit="%", ), FuelConsumption(), Sensor( attr="distanceToEmpty", name="Range", icon="mdi:ruler", unit="km" ), Sensor( attr="averageSpeed", name="Average speed", icon="mdi:ruler", unit="km/h", ), Sensor( attr="hvBattery.distanceToHVBatteryEmpty", name="Battery range", icon="mdi:ruler", unit="km", ), Sensor( attr="hvBattery.hvBatteryLevel", name="Battery level", icon="mdi:battery", unit="%", ), Sensor( attr="hvBattery.timeToHVBatteryFullyCharged", name="Time to fully charged", icon="mdi:clock", unit="minutes", ), BatteryChargeStatus(), PluggedInStatus(), EngineStart(), JournalLastTrip(), BinarySensor( attr="is_engine_running", name="Engine", device_class="power" ), BinarySensor(attr="is_locked", name="Door lock", device_class="lock"), BinarySensor(attr="doors.hoodOpen", name="Hood", device_class="door"), BinarySensor( attr="doors.tailgateOpen", name="Tailgate", device_class="door" ), BinarySensor( attr="doors.frontLeftDoorOpen", name="Front left door", device_class="door", ), BinarySensor( attr="doors.frontRightDoorOpen", name="Front right door", device_class="door", ), BinarySensor( attr="doors.rearLeftDoorOpen", name="Rear left door", device_class="door", ), BinarySensor( attr="doors.rearRightDoorOpen", name="Rear right door", device_class="door", ), BinarySensor( attr="windows.frontLeftWindowOpen", name="Front left window", device_class="window", ), BinarySensor( attr="windows.frontRightWindowOpen", name="Front right window", device_class="window", ), BinarySensor( attr="windows.rearLeftWindowOpen", name="Rear left window", device_class="window", ), BinarySensor( attr="windows.rearRightWindowOpen", name="Rear right window", device_class="window", ), BinarySensor( attr="tyrePressure.frontRightTyrePressure", name=f"Front right {tyre}", device_class="safety", ), BinarySensor( attr="tyrePressure.frontLeftTyrePressure", name=f"Front left {tyre}", device_class="safety", ), BinarySensor( attr="tyrePressure.rearRightTyrePressure", name=f"Rear right {tyre}", device_class="safety", ), BinarySensor( attr="tyrePressure.rearLeftTyrePressure", name=f"Rear left {tyre}", device_class="safety", ), BinarySensor( attr="washerFluidLevel", name="Washer fluid", device_class="safety" ), BinarySensor( attr="brakeFluid", name="Brake Fluid", device_class="safety" ), BinarySensor( attr="serviceWarningStatus", name="Service", device_class="safety" ), BinarySensor(attr="bulbFailures", name="Bulbs", device_class="safety"), BinarySensor(attr="any_door_open", name="Doors", device_class="door"), BinarySensor( attr="any_window_open", name="Windows", device_class="window" ), ] class Dashboard: def __init__(self, vehicle, **config): _LOGGER.debug("Setting up dashboard with config :%s", config) self.instruments = [ instrument for instrument in create_instruments(config) if instrument.setup(vehicle, **config) ] molobrakos-volvooncall-8b82c97/volvooncall/mqtt.py000077500000000000000000000322401443322111300224610ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- mode: python; coding: utf-8 -*- import logging from json import dumps as dump_json from os import environ as env from os.path import expanduser, join from time import time import string from platform import node as hostname import asyncio import certifi from amqtt.client import ClientException, ConnectException, MQTTClient from .dashboard import Lock, Position, Sensor, BinarySensor, Switch from .util import camel2slug, whitelisted, owntracks_encrypt _LOGGER = logging.getLogger(__name__) STATE_ON = "on" STATE_OFF = "off" STATE_ONLINE = "online" STATE_OFFLINE = "offline" STATE_LOCK = "lock" STATE_UNLOCK = "unlock" STATE_OPEN = "open" STATE_CLOSE = "close" STATE_SAFE = "safe" STATE_UNSAFE = "unsafe" DISCOVERY_PREFIX = "homeassistant" TOPIC_PREFIX = "volvo" CONF_OWNTRACKS_KEY = "owntracks_key" TOPIC_WHITELIST = "_-" + string.ascii_letters + string.digits TOPIC_SUBSTITUTE = "_" def make_valid_hass_single_topic_level(s): """Transform a multi level topic to a single level. >>> make_valid_hass_single_topic_level('foo/bar/baz') 'foo_bar_baz' >>> make_valid_hass_single_topic_level('hello å ä ö') 'hello______' """ return whitelisted(s, TOPIC_WHITELIST, TOPIC_SUBSTITUTE) def make_topic(*levels): """Create a valid topic. >>> make_topic('foo', 'bar') 'foo/bar' >>> make_topic(('foo', 'bar')) 'foo/bar' """ if len(levels) == 1 and isinstance(levels[0], tuple): return make_topic(*levels[0]) return "/".join(levels) def read_mqtt_config(): """Read config from ~/.config/mosquitto_pub.""" fname = join( env.get("XDG_CONFIG_HOME", join(expanduser("~"), ".config")), "mosquitto_pub", ) try: with open(fname) as f: d = dict( line.replace("-", "").split() for line in f.read().splitlines() ) return dict( host=d["h"], port=d["p"], username=d["username"], password=d["pw"], ) except KeyError as error: exit("Could not parse MQTT config in %s: %s" % (fname, error)) except FileNotFoundError: exit("Could not find MQTT config: %s" % fname) class Entity: subscriptions = {} def __init__(self, client, instrument, config): self.client = client self.instrument = instrument self.config = config def __repr__(self): return self.instrument.name @classmethod def route_message(cls, topic, payload): entity = Entity.subscriptions.get(topic) if entity: entity.receive_command(payload) else: _LOGGER.warning("No subscriber to message on topic %s", topic) @property def vehicle(self): return self.instrument.vehicle @property def attr(self): return self.instrument.attr @property def name(self): return self.instrument.full_name @property def state(self): state = self.instrument.state if state is None: return None if self.is_lock: return (STATE_UNLOCK, STATE_LOCK)[state] elif self.is_switch: return (STATE_OFF, STATE_ON)[state] elif self.is_opening: return (STATE_CLOSE, STATE_OPEN)[state] elif self.is_safety: return (STATE_SAFE, STATE_UNSAFE)[state] elif self.is_binary_sensor: return (STATE_OFF, STATE_ON)[state] elif self.is_position: lat, lon, timestamp, speed, heading = state key = self.config.get(CONF_OWNTRACKS_KEY) res = dict( _type="location", tid="volvo", t="p", lat=lat, lon=lon, acc=1, tst=int(time()), now=int(time()), ) if timestamp is not None: res["tst"] = int(timestamp.timestamp()) res["tst_iso"] = timestamp.isoformat() if speed is not None: res["speed"] = speed if heading is not None: res["heading"] = heading return ( dict( _type="encrypted", data=owntracks_encrypt(dump_json(res), key), ) if key else res ) else: return state @property def discovery_node_id(self): return make_valid_hass_single_topic_level( make_topic(TOPIC_PREFIX, self.vehicle.unique_id) ) @property def object_id(self): return make_valid_hass_single_topic_level(camel2slug(self.attr)) @property def discovery_topic(self): return make_topic( DISCOVERY_PREFIX, self.instrument.component, self.discovery_node_id, self.object_id, "config", ) @property def topic(self): return make_topic( TOPIC_PREFIX, self.vehicle.unique_id, self.instrument.component, self.object_id, ) def make_topic(self, *levels): return make_topic(self.topic, *levels) @property def state_topic(self): if self.is_position: return make_topic("owntracks", "volvo", self.vehicle.unique_id) else: return self.make_topic("state") @property def availability_topic(self): return self.make_topic("avail") @property def command_topic(self): return self.make_topic("cmd") @property def discovery_payload(self): instrument = self.instrument payload = dict( name=instrument.full_name, state_topic=self.state_topic, availability_topic=self.availability_topic, payload_available=STATE_ONLINE, payload_not_available=STATE_OFFLINE, ) if self.is_mutable: payload.update(command_topic=self.command_topic) if self.is_sensor: return dict( payload, icon=instrument.icon, unit_of_measurement=instrument.unit, ) elif self.is_opening: return dict( payload, payload_on=STATE_OPEN, payload_off=STATE_CLOSE, device_class=instrument.device_class, ) elif self.is_safety: return dict( payload, payload_on=STATE_UNSAFE, payload_off=STATE_SAFE, device_class=instrument.device_class, ) elif self.is_binary_sensor: return dict( payload, payload_on=STATE_ON, payload_off=STATE_OFF, device_class=instrument.device_class, ) elif self.is_lock: return dict( payload, payload_lock=STATE_LOCK, payload_unlock=STATE_UNLOCK, optimistic=True, ) elif self.is_switch: return dict( payload, payload_on=STATE_ON, payload_off=STATE_OFF, icon=instrument.icon, optimistic=True, ) else: _LOGGER.error("Huh?") async def publish(self, topic, payload, retain=False): payload = ( dump_json(payload) if isinstance(payload, dict) else str(payload) ) _LOGGER.debug("Publishing on %s: %s", topic, payload) await self.client.publish( topic, payload.encode("utf-8"), retain=retain ) _LOGGER.debug("Published on %s: %s", topic, payload) async def subscribe_to(self, topic): _LOGGER.debug("Subscribing to %s", topic) from amqtt.mqtt.constants import QOS_1 await self.client.subscribe([(topic, QOS_1)]) _LOGGER.debug("Subscribed to %s", topic) Entity.subscriptions[topic] = self def receive_command(self, command): run = asyncio.create_task # pylint:disable=no-member if self.is_lock: if command == STATE_LOCK: run(self.instrument.lock()) elif command == STATE_UNLOCK: run(self.instrument.unlock()) else: _LOGGER.info("Skipping unknown payload %s", command) elif self.is_switch: if command == STATE_ON: run(self.instrument.turn_on()) elif command == STATE_OFF: run(self.instrument.turn_off()) else: _LOGGER.info("Skipping unknown payload %s", command) else: _LOGGER.warning("No command to execute for %s: %s", self, command) @property def is_mutable(self): return self.is_lock or self.is_switch @property def is_sensor(self): return isinstance(self.instrument, Sensor) @property def is_binary_sensor(self): return isinstance(self.instrument, BinarySensor) @property def is_opening(self): return self.is_binary_sensor and self.instrument.device_class in [ "door", "window", ] @property def is_safety(self): return ( self.is_binary_sensor and self.instrument.device_class == "safety" ) @property def is_switch(self): return isinstance(self.instrument, Switch) @property def is_position(self): return isinstance(self.instrument, Position) @property def is_lock(self): return isinstance(self.instrument, Lock) async def publish_discovery(self): if self.is_position: return if self.is_mutable: await self.subscribe_to(self.command_topic) await self.publish(self.discovery_topic, self.discovery_payload) async def publish_availability(self, available): if self.is_position: return await self.publish( self.availability_topic, STATE_ONLINE if available and self.state is not None else STATE_OFFLINE, ) async def publish_state(self): if self.state is not None: _LOGGER.debug("State for %s: %s", self.attr, self.state) await self.publish(self.state_topic, self.state) else: _LOGGER.warning("No state available for %s", self) async def run(voc, config): logging.getLogger("amqtt.client.plugins.packet_logger_plugin").setLevel( logging.WARNING ) client_id = "voc_{hostname}_{time}".format( hostname=hostname(), time=time() ) mqtt = MQTTClient(client_id=client_id) url = config.get("mqtt_url") if url: _LOGGER.debug("Using MQTT url from voc.conf") else: _LOGGER.debug("Using MQTT url from mosquitto_pub") mqtt_config = read_mqtt_config() try: username = mqtt_config["username"] password = mqtt_config["password"] host = mqtt_config["host"] port = mqtt_config["port"] url = "mqtts://{username}:{password}@{host}:{port}".format( username=username, password=password, host=host, port=port ) except Exception as e: exit(e) entities = {} async def mqtt_task(): try: await mqtt.connect(url, cleansession=False, cafile=certifi.where()) _LOGGER.info("Connected to MQTT server") except ConnectException as e: exit("Could not connect to MQTT server: %s" % e) while True: _LOGGER.debug("Waiting for messages") try: message = await mqtt.deliver_message() packet = message.publish_packet topic = packet.variable_header.topic_name payload = packet.payload.data.decode("ascii") _LOGGER.debug("got message on %s: %s", topic, payload) Entity.route_message(topic, payload) except ClientException as e: _LOGGER.error("MQTT Client exception: %s", e) asyncio.create_task(mqtt_task()) # pylint:disable=no-member interval = int(config["interval"]) _LOGGER.info("Polling VOC every %d seconds", interval) while True: available = await voc.update(journal=True) wait_list = [] for vehicle in voc.vehicles: if vehicle not in entities: _LOGGER.debug("creating vehicle %s", vehicle) dashboard = vehicle.dashboard(**config) entities[vehicle] = [ Entity(mqtt, instrument, config) for instrument in dashboard.instruments ] for entity in entities[vehicle]: _LOGGER.debug( "%s: %s", entity.instrument.full_name, entity.state ) wait_list.append(entity.publish_discovery()) wait_list.append(entity.publish_availability(available)) if available: wait_list.append(entity.publish_state()) await asyncio.gather(*wait_list) _LOGGER.debug("Waiting for new VOC update in %d seconds", interval) await asyncio.sleep(interval) molobrakos-volvooncall-8b82c97/volvooncall/util.py000066400000000000000000000071631443322111300224540ustar00rootroot00000000000000from datetime import date, datetime from base64 import b64encode from string import ascii_letters as letters, digits from sys import argv from os import environ as env from os.path import join, dirname, expanduser from itertools import product import json import logging import re _LOGGER = logging.getLogger(__name__) def read_config(): """Read config from file.""" for directory, filename in product( [ dirname(argv[0]), expanduser("~"), env.get("XDG_CONFIG_HOME", join(expanduser("~"), ".config")), ], ["voc.conf", ".voc.conf"], ): try: config = join(directory, filename) _LOGGER.debug("checking for config file %s", config) with open(config) as config: return dict( x.split(": ") for x in config.read().strip().splitlines() if not x.startswith("#") ) except OSError: continue return {} def obj_parser(obj): """Parse datetime.""" for key, val in obj.items(): try: obj[key] = datetime.strptime(val, "%Y-%m-%dT%H:%M:%S%z") except (TypeError, ValueError): pass return obj def json_serialize(obj): """JSON serializer for objects not serializable by default json code""" if isinstance(obj, (datetime, date)): return obj.isoformat() raise TypeError("Type %s not serializable" % type(obj)) def json_loads(s): return json.loads(s, object_hook=obj_parser) def find_path(src, path): """Simple navigation of a hierarchical dict structure using XPATH-like syntax. >>> find_path(dict(a=1), 'a') 1 >>> find_path(dict(a=1), '') {'a': 1} >>> find_path(dict(a=None), 'a') >>> find_path(dict(a=1), 'b') Traceback (most recent call last): ... KeyError: 'b' >>> find_path(dict(a=dict(b=1)), 'a.b') 1 >>> find_path(dict(a=dict(b=1)), 'a') {'b': 1} >>> find_path(dict(a=dict(b=1)), 'a.c') Traceback (most recent call last): ... KeyError: 'c' """ if not path: return src if isinstance(path, str): path = path.split(".") return find_path(src[path[0]], path[1:]) def is_valid_path(src, path): """ >>> is_valid_path(dict(a=1), 'a') True >>> is_valid_path(dict(a=1), '') True >>> is_valid_path(dict(a=1), None) True >>> is_valid_path(dict(a=1), 'b') False """ try: find_path(src, path) return True except KeyError: return False def owntracks_encrypt(msg, key): try: from libnacl import crypto_secretbox_KEYBYTES as keylen from libnacl.secret import SecretBox as secret key = key.encode("utf-8") key = key[:keylen] key = key.ljust(keylen, b"\0") msg = msg.encode("utf-8") ciphertext = secret(key).encrypt(msg) ciphertext = b64encode(ciphertext) ciphertext = ciphertext.decode("ascii") return ciphertext except ImportError: exit("libnacl missing") except OSError: exit("libsodium missing") def camel2slug(s): """Convert camelCase to camel_case. >>> camel2slug('fooBar') 'foo_bar' """ return re.sub("([A-Z])", "_\\1", s).lower().lstrip("_") def whitelisted(s, whitelist=letters + digits, substitute=""): """ >>> whitelisted("ab/cd#ef(gh") 'abcdefgh' >>> whitelisted("ab/cd#ef(gh", substitute="_") 'ab_cd_ef_gh' >>> whitelisted("ab/cd#ef(gh", substitute='') 'abcdefgh' """ return "".join(c if c in whitelist else substitute for c in s) molobrakos-volvooncall-8b82c97/volvooncall/volvooncall.py000077500000000000000000000320731443322111300240360ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Communicate with VOC server.""" import logging from datetime import timedelta from json import dumps as to_json from collections import OrderedDict from sys import argv from urllib.parse import urljoin import asyncio from aiohttp import ClientSession, ClientTimeout, BasicAuth from aiohttp.hdrs import METH_GET, METH_POST from .util import ( json_serialize, is_valid_path, find_path, json_loads, read_config, ) __version__ = "0.10.4" _LOGGER = logging.getLogger(__name__) SERVICE_URL = "https://vocapi{region}.wirelesscar.net/customerapi/rest/v3.0/" DEFAULT_SERVICE_URL = SERVICE_URL.format(region="") HEADERS = { "X-Device-Id": "Device", "X-OS-Type": "Android", "X-Originator-Type": "App", "X-OS-Version": "22", "Content-Type": "application/json", } TIMEOUT = timedelta(seconds=30) class Connection: """Connection to the VOC server.""" def __init__( self, session, username, password, service_url=None, region=None, **_ ): """Initialize.""" _LOGGER.info("%s %s %s", __name__, __version__, __file__) self._session = session self._auth = BasicAuth(username, password) self._service_url = ( SERVICE_URL.format(region="-" + region) if region else service_url or DEFAULT_SERVICE_URL ) self._state = {} _LOGGER.debug("Using service <%s>", self._service_url) _LOGGER.debug("User: <%s>", username) async def _request(self, method, url, **kwargs): """Perform a query to the online service.""" try: _LOGGER.debug("Request for %s", url) async with self._session.request( method, url, headers=HEADERS, auth=self._auth, timeout=ClientTimeout(total=TIMEOUT.seconds), **kwargs ) as response: response.raise_for_status() res = await response.json(loads=json_loads) _LOGGER.debug("Received %s", res) return res except Exception as error: _LOGGER.warning( "Failure when communicating with the server: %s", error, exc_info=True, ) raise def _make_url(self, ref, rel=None): return urljoin(rel or self._service_url, ref) async def get(self, url, rel=None): """Perform a query to the online service.""" return await self._request(METH_GET, self._make_url(url, rel)) async def post(self, url, rel=None, **data): """Perform a query to the online service.""" return await self._request( METH_POST, self._make_url(url, rel), json=data ) async def update(self, journal=False, reset=False): """Update status.""" try: _LOGGER.info("Updating") if not self._state or reset: _LOGGER.info("Querying vehicles") user = await self.get("customeraccounts") _LOGGER.debug("Account for <%s> received", user["username"]) self._state = {} for vehicle in user["accountVehicleRelations"]: rel = await self.get(vehicle) if rel.get("status") == "Verified": url = rel["vehicle"] + "/" state = await self.get("attributes", rel=url) self._state.update({url: state}) else: _LOGGER.warning("vehichle not verified") for vehicle in self.vehicles: await vehicle.update(journal=journal) _LOGGER.debug("State: %s", self._state) return True except (OSError, LookupError) as error: _LOGGER.warning("Could not query server: %s", error) async def update_vehicle(self, vehicle, journal=False): url = vehicle._url self._state[url].update(await self.get("status", rel=url)) self._state[url].update(await self.get("position", rel=url)) if journal: self._state[url].update(await self.get("trips", rel=url)) @property def vehicles(self): """Return vehicle state.""" return (Vehicle(self, url) for url in self._state) def vehicle(self, vin): """Return vehicle for given vin.""" return next( ( vehicle for vehicle in self.vehicles if vehicle.unique_id == vin.lower() ), None, ) def vehicle_attrs(self, vehicle_url): return self._state.get(vehicle_url) class Vehicle(object): """Convenience wrapper around the state returned from the server.""" def __init__(self, conn, url): self._connection = conn self._url = url async def update(self, journal=False): await self._connection.update_vehicle(self, journal) @property def attrs(self): return self._connection.vehicle_attrs(self._url) def has_attr(self, attr): return is_valid_path(self.attrs, attr) def get_attr(self, attr): return find_path(self.attrs, attr) @property def unique_id(self): return (self.registration_number or self.vin).lower() @property def position(self): return self.attrs.get("position") @property def registration_number(self): return self.attrs.get("registrationNumber") @property def vin(self): return self.attrs.get("vin") @property def model_year(self): return self.attrs.get("modelYear") @property def vehicle_type(self): return self.attrs.get("vehicleType") @property def odometer(self): return self.attrs.get("odometer") @property def fuel_amount_level(self): return self.attrs.get("fuelAmountLevel") @property def distance_to_empty(self): return self.attrs.get("distanceToEmpty") @property def is_honk_and_blink_supported(self): return self.attrs.get("honkAndBlinkSupported") @property def doors(self): return self.attrs.get("doors") @property def windows(self): return self.attrs.get("windows") @property def is_lock_supported(self): return self.attrs.get("lockSupported") @property def is_unlock_supported(self): return self.attrs.get("unlockSupported") @property def is_locked(self): return self.attrs.get("carLocked") @property def heater(self): return self.attrs.get("heater") @property def is_remote_heater_supported(self): return self.attrs.get("remoteHeaterSupported") @property def is_preclimatization_supported(self): return self.attrs.get("preclimatizationSupported") @property def is_journal_supported(self): return self.attrs.get("journalLogSupported") and self.attrs.get( "journalLogEnabled" ) @property def is_engine_running(self): engine_remote_start_status = ( self.attrs.get("ERS", {}).get("status") or "" ) return ( self.attrs.get("engineRunning") or "on" in engine_remote_start_status ) @property def is_engine_start_supported(self): return self.attrs.get("engineStartSupported") and self.attrs.get("ERS") async def get(self, query): """Perform a query to the online service.""" return await self._connection.get(query, self._url) async def post(self, query, **data): """Perform a query to the online service.""" return await self._connection.post(query, self._url, **data) async def call(self, method, **data): """Make remote method call.""" try: res = await self.post(method, **data) if "service" not in res or "status" not in res: _LOGGER.warning("Failed to execute: %s", res["status"]) return if res["status"] not in ["Queued", "Started"]: _LOGGER.warning("Failed to execute: %s", res["status"]) return # if Queued -> wait? service_url = res["service"] res = await self.get(service_url) if "status" not in res: _LOGGER.warning("Message not delivered") return # if still Queued -> wait? if res["status"] not in [ "MessageDelivered", "Successful", "Started", ]: _LOGGER.warning("Message not delivered: %s", res["status"]) return _LOGGER.debug("Message delivered") return True except Exception as error: _LOGGER.warning("Failure to execute: %s", error) @staticmethod def any_open(doors): """ >>> Vehicle.any_open({'frontLeftWindowOpen': False, ... 'frontRightWindowOpen': False, ... 'timestamp': 'foo'}) False >>> Vehicle.any_open({'frontLeftWindowOpen': True, ... 'frontRightWindowOpen': False, ... 'timestamp': 'foo'}) True """ return doors and any(doors[door] for door in doors if "Open" in door) @property def any_window_open(self): return self.any_open(self.windows) @property def any_door_open(self): return self.any_open(self.doors) @property def position_supported(self): """Return true if vehicle has position.""" return "position" in self.attrs @property def heater_supported(self): """Return true if vehicle has heater.""" return ( self.is_remote_heater_supported or self.is_preclimatization_supported ) and "heater" in self.attrs @property def is_heater_on(self): """Return status of heater.""" return ( self.heater_supported and "status" in self.heater and self.heater["status"] != "off" ) @property def trips(self): """Return trips.""" return self.attrs.get("trips") async def honk_and_blink(self): """Honk and blink.""" if self.is_honk_and_blink_supported: await self.call("honkAndBlink") async def lock(self): """Lock.""" if self.is_lock_supported: await self.call("lock") await self.update() else: _LOGGER.warning("Lock not supported") async def unlock(self): """Unlock.""" if self.is_unlock_supported: await self.call("unlock") await self.update() else: _LOGGER.warning("Unlock not supported") async def start_engine(self): if self.is_engine_start_supported: await self.call("engine/start", runtime=15) await self.update() else: _LOGGER.warning("Engine start not supported.") async def stop_engine(self): if self.is_engine_start_supported: await self.call("engine/stop") await self.update() else: _LOGGER.warning("Engine stop not supported.") async def start_heater(self): """Turn on/off heater.""" if self.is_remote_heater_supported: await self.call("heater/start") await self.update() elif self.is_preclimatization_supported: await self.call("preclimatization/start") await self.update() else: _LOGGER.warning("No heater or preclimatization support.") async def stop_heater(self): """Turn on/off heater.""" if self.is_remote_heater_supported: await self.call("heater/stop") await self.update() elif self.is_preclimatization_supported: await self.call("preclimatization/stop") await self.update() else: _LOGGER.warning("No heater or preclimatization support.") def __str__(self): return "%s (%s/%s) %s" % ( self.registration_number or "?", self.vehicle_type or "?", self.model_year or "?", self.vin or "?", ) def dashboard(self, **config): from .dashboard import Dashboard return Dashboard(self, **config) @property def json(self): """Return JSON representation.""" return to_json( OrderedDict(sorted(self.attrs.items())), indent=4, default=json_serialize, ) async def main(): """Main method.""" if "-v" in argv: logging.basicConfig(level=logging.INFO) elif "-vv" in argv: logging.basicConfig(level=logging.DEBUG) else: logging.basicConfig(level=logging.ERROR) async with ClientSession() as session: connection = Connection(session, **read_config()) if await connection.update(): for vehicle in connection.vehicles: print(vehicle) if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run(main())