├── .github └── workflows │ ├── main.yml │ └── validate.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── custom_components └── bvg_berlin_public_transport │ ├── __init__.py │ ├── api.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── entity.py │ ├── manifest.json │ ├── sensor.py │ ├── switch.py │ └── translations │ └── en.json ├── example.png ├── hacs.json ├── info.md └── setup.cfg /.github/workflows/main.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@v2" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 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@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People _love_ thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) 48 | to make sure the code follows the style. 49 | 50 | Or use the `pre-commit` settings implemented in this repository 51 | (see deicated section below). 52 | 53 | ## Test your code modification 54 | 55 | This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 56 | 57 | It comes with development environment in a container, easy to launch 58 | if you use Visual Studio Code. With this container you will have a stand alone 59 | Home Assistant instance running and already configured with the included 60 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 61 | file. 62 | 63 | You can use the `pre-commit` settings implemented in this repository to have 64 | linting tool checking your contributions (see deicated section below). 65 | 66 | 67 | 68 | ## Pre-commit 69 | 70 | You can use the [pre-commit](https://pre-commit.com/) settings included in the 71 | repostory to have code style and linting checks. 72 | 73 | With `pre-commit` tool already installed, 74 | activate the settings of the repository: 75 | 76 | ```console 77 | $ pre-commit install 78 | ``` 79 | 80 | Now the pre-commit tests will be done every time you commit. 81 | 82 | You can run the tests on all repository file with the command: 83 | 84 | ```console 85 | $ pre-commit run --all-files 86 | ``` 87 | 88 | ## License 89 | 90 | By contributing, you agree that your contributions will be licensed under its MIT License. 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BVG (Berlin Public Transport) 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![License][license-shield]](LICENSE) 5 | 6 | [![hacs][hacsbadge]][hacs] 7 | [![Project Maintenance][maintenance-shield]][user_profile] 8 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 9 | 10 | 11 | ## Overview 12 | 13 | This is an integration for [Home Assistant](https://www.home-assistant.io/) (a tool for managing and viewing your 'smart' devices at home). This integration adds the ability to track the next departure times for bus and trains stops on Berlin Public Transport. 14 | 15 | It is an extension of work by [@tobias-richter](https://github.com/tobias-richter) and [@fluffykraken](https://github.com/fluffykraken), forked to be made HACS ready and to pick up (seemingly) abandoned code. 16 | 17 | ## HACS Installation 18 | 19 | 1. Open HACS and select `Integrations` 20 | 2. Click `Explore and Download Repositories` 21 | 3. Type 'bvg' into the search 22 | 4. Click 'Download this repo with HACS' 23 | 5. Restart Home Assistant 24 | 25 | ## Manual Installation 26 | 27 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 28 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 29 | 3. In the `custom_components` directory (folder) create a new folder called `bvg_hacs`. 30 | 4. Download _all_ the files from the `custom_components/bvg_hacs/` directory (folder) in this repository. 31 | 5. Place the files you downloaded in the new directory (folder) you created. 32 | 6. Restart Home Assistant 33 | 34 | # Prequisites 35 | 36 | You will need to specify at least a ``stop_id`` and a ``direction`` for the connection you would like to display. 37 | 38 | To find your ``stop_id`` use the following link: [https://1.bvg.transport.rest/stations/nearby?latitude=52.52725&longitude=13.4123](https://v5.bvg.transport.rest/stops/nearby?latitude=52.52725&longitude=13.4123) and replace the values for ```latitude=``` and ```longitude=``` with your coordinates. You can get those e.g. from Google Maps. 39 | Find your `stop_id` within the json repsonse in your browser. 40 | 41 | ### Example: 42 | You want to display the departure times from "U Rosa-Luxemburg-Platz" in direction to "Pankow" 43 | 44 | #### get the stop_id: 45 | 46 | Link: [https://1.bvg.transport.rest/stations/nearby?latitude=52.52725&longitude=13.4123](https://v5.bvg.transport.rest/stops/nearby?latitude=52.52725&longitude=13.4123) 47 | 48 | `` 49 | {"type":"stop","id":"900000100016","name":"U Rosa-Luxemburg-Platz","location":{"type":"location","latitude":52.528187,"longitude":13.410405},"products":{"suburban":false,"subway":true,"tram":true,"bus":true,"ferry":false,"express":false,"regional":false},"distance":165} 50 | `` 51 | 52 | Your ``stop_id`` for ``"U Rosa-Luxemburg-Platz"`` would be ``"900000100016"`` 53 | 54 | #### get the direction: 55 | 56 | Specify the final destination (must be a valid station name) for the connection you want to display. In this example this would be ``Pankow``. If your route is beeing served by multiple lines with different directions, you can define multiple destinations in your config. 57 | 58 | ```yaml 59 | # Example configuration.yaml entry 60 | - platform: bvg_berlin_public_transport 61 | stop_id: your stop id 62 | direction: 63 | - "destionation 1" 64 | - "destination 2" 65 | ```` 66 | 67 | # Configuration 68 | 69 | To add the BVG Sensor Component to Home Assistant, add the following to your `configuration.yaml` file: 70 | 71 | ```yaml 72 | # Example configuration.yaml entry 73 | - platform: bvg_berlin_public_transport 74 | stop_id: your stop id 75 | direction: the final destination for your connection 76 | ```` 77 | 78 | - **stop_id** *(Required)*: The stop_id for your station. 79 | - **direction** *(Required)*: One or more destinations for your route. 80 | - **name** *(optional)*: Name your sensor, especially if you create multiple instance of the sensor give them different names. * (Default=BVG)* 81 | - **walking_distance** *(optional)*: specify the walking distance in minutes from your home/location to the station. Only connections that are reachable in a timley manner will be shown. Set it to ``0`` if you want to disable this feature. *(Default=10)* 82 | - **file_path** *(optional)*: path where you want your station specific data to be saved. *(Default= your home assistant config directory e.g. "conf/" )* 83 | 84 | ### Example Configuration: 85 | ```yaml 86 | sensor: 87 | - platform: bvg_berlin_public_transport 88 | name: U2 Rosa-Luxemburg-Platz 89 | stop_id: "900000100016" 90 | direction: "Pankow" 91 | walking_distance: 5 92 | file_path: "/tmp/" 93 | ``` 94 | 95 | 96 | 97 | ## Credits 98 | 99 | Core sensor code was written by It is an extension of work by [@fluffykraken](https://github.com/fluffykraken) and updated by [@tobias-richter](tobias-richter). 100 | 101 | This project was generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter](https://github.com/oncleben31/cookiecutter-homeassistant-custom-component) template. 102 | 103 | [buymecoffee]: https://www.buymeacoffee.com/secretdarkR 104 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 105 | [hacs]: https://hacs.xyz 106 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 107 | [exampleimg]: example.png 108 | [license-shield]: https://img.shields.io/github/license/ryanbateman/bvg_hacs.svg?style=for-the-badge 109 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40ryanbateman-blue.svg?style=for-the-badge 110 | [releases-shield]: https://img.shields.io/github/release/ryanbateman/bvg_hacs.svg?style=for-the-badge 111 | [releases]: https://github.com/ryanbateman/bvg_hacs/releases 112 | [user_profile]: https://github.com/ryanbateman 113 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate BVG (Berlin Public Transport) with Home Assistant. 3 | 4 | For more details about this integration, please refer to 5 | https://github.com/ryanbateman/bvg_hacs 6 | """ 7 | import asyncio 8 | import logging 9 | from datetime import timedelta 10 | 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import Config 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import ConfigEntryNotReady 15 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 16 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 17 | from homeassistant.helpers.update_coordinator import UpdateFailed 18 | 19 | from .api import BvgBerlinPublicTransportApiClient 20 | from .const import CONF_PASSWORD 21 | from .const import CONF_USERNAME 22 | from .const import DOMAIN 23 | from .const import PLATFORMS 24 | from .const import STARTUP_MESSAGE 25 | 26 | SCAN_INTERVAL = timedelta(seconds=30) 27 | 28 | _LOGGER: logging.Logger = logging.getLogger(__package__) 29 | 30 | 31 | async def async_setup(hass: HomeAssistant, config: Config): 32 | """Set up this integration using YAML is not supported.""" 33 | return True 34 | 35 | 36 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 37 | """Set up this integration using UI.""" 38 | if hass.data.get(DOMAIN) is None: 39 | hass.data.setdefault(DOMAIN, {}) 40 | _LOGGER.info(STARTUP_MESSAGE) 41 | 42 | username = entry.data.get(CONF_USERNAME) 43 | password = entry.data.get(CONF_PASSWORD) 44 | 45 | session = async_get_clientsession(hass) 46 | client = BvgBerlinPublicTransportApiClient(username, password, session) 47 | 48 | coordinator = BvgBerlinPublicTransportDataUpdateCoordinator(hass, client=client) 49 | await coordinator.async_refresh() 50 | 51 | if not coordinator.last_update_success: 52 | raise ConfigEntryNotReady 53 | 54 | hass.data[DOMAIN][entry.entry_id] = coordinator 55 | 56 | for platform in PLATFORMS: 57 | if entry.options.get(platform, True): 58 | coordinator.platforms.append(platform) 59 | hass.async_add_job( 60 | hass.config_entries.async_forward_entry_setup(entry, platform) 61 | ) 62 | 63 | entry.add_update_listener(async_reload_entry) 64 | return True 65 | 66 | 67 | class BvgBerlinPublicTransportDataUpdateCoordinator(DataUpdateCoordinator): 68 | """Class to manage fetching data from the API.""" 69 | 70 | def __init__( 71 | self, 72 | hass: HomeAssistant, 73 | client: BvgBerlinPublicTransportApiClient, 74 | ) -> None: 75 | """Initialize.""" 76 | self.api = client 77 | self.platforms = [] 78 | 79 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) 80 | 81 | async def _async_update_data(self): 82 | """Update data via library.""" 83 | try: 84 | return await self.api.async_get_data() 85 | except Exception as exception: 86 | raise UpdateFailed() from exception 87 | 88 | 89 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 90 | """Handle removal of an entry.""" 91 | coordinator = hass.data[DOMAIN][entry.entry_id] 92 | unloaded = all( 93 | await asyncio.gather( 94 | *[ 95 | hass.config_entries.async_forward_entry_unload(entry, platform) 96 | for platform in PLATFORMS 97 | if platform in coordinator.platforms 98 | ] 99 | ) 100 | ) 101 | if unloaded: 102 | hass.data[DOMAIN].pop(entry.entry_id) 103 | 104 | return unloaded 105 | 106 | 107 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 108 | """Reload config entry.""" 109 | await async_unload_entry(hass, entry) 110 | await async_setup_entry(hass, entry) 111 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/api.py: -------------------------------------------------------------------------------- 1 | """Sample API Client.""" 2 | import asyncio 3 | import logging 4 | import socket 5 | 6 | import aiohttp 7 | import async_timeout 8 | 9 | TIMEOUT = 10 10 | 11 | 12 | _LOGGER: logging.Logger = logging.getLogger(__package__) 13 | 14 | HEADERS = {"Content-type": "application/json; charset=UTF-8"} 15 | 16 | 17 | class BvgBerlinPublicTransportApiClient: 18 | def __init__( 19 | self, username: str, password: str, session: aiohttp.ClientSession 20 | ) -> None: 21 | """Sample API Client.""" 22 | self._username = username 23 | self._passeword = password 24 | self._session = session 25 | 26 | async def async_get_data(self) -> dict: 27 | """Get data from the API.""" 28 | url = "https://jsonplaceholder.typicode.com/posts/1" 29 | return await self.api_wrapper("get", url) 30 | 31 | async def async_set_title(self, value: str) -> None: 32 | """Get data from the API.""" 33 | url = "https://jsonplaceholder.typicode.com/posts/1" 34 | await self.api_wrapper("patch", url, data={"title": value}, headers=HEADERS) 35 | 36 | async def api_wrapper( 37 | self, method: str, url: str, data: dict = {}, headers: dict = {} 38 | ) -> dict: 39 | """Get information from the API.""" 40 | try: 41 | async with async_timeout.timeout(TIMEOUT, loop=asyncio.get_event_loop()): 42 | if method == "get": 43 | response = await self._session.get(url, headers=headers) 44 | return await response.json() 45 | 46 | elif method == "put": 47 | await self._session.put(url, headers=headers, json=data) 48 | 49 | elif method == "patch": 50 | await self._session.patch(url, headers=headers, json=data) 51 | 52 | elif method == "post": 53 | await self._session.post(url, headers=headers, json=data) 54 | 55 | except asyncio.TimeoutError as exception: 56 | _LOGGER.error( 57 | "Timeout error fetching information from %s - %s", 58 | url, 59 | exception, 60 | ) 61 | 62 | except (KeyError, TypeError) as exception: 63 | _LOGGER.error( 64 | "Error parsing information from %s - %s", 65 | url, 66 | exception, 67 | ) 68 | except (aiohttp.ClientError, socket.gaierror) as exception: 69 | _LOGGER.error( 70 | "Error fetching information from %s - %s", 71 | url, 72 | exception, 73 | ) 74 | except Exception as exception: # pylint: disable=broad-except 75 | _LOGGER.error("Something really wrong happened! - %s", exception) 76 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for BVG (Berlin Public Transport).""" 2 | from homeassistant.components.binary_sensor import BinarySensorEntity 3 | 4 | from .const import BINARY_SENSOR 5 | from .const import BINARY_SENSOR_DEVICE_CLASS 6 | from .const import DEFAULT_NAME 7 | from .const import DOMAIN 8 | from .entity import BvgBerlinPublicTransportEntity 9 | 10 | 11 | async def async_setup_entry(hass, entry, async_add_devices): 12 | """Setup binary_sensor platform.""" 13 | coordinator = hass.data[DOMAIN][entry.entry_id] 14 | async_add_devices([BvgBerlinPublicTransportBinarySensor(coordinator, entry)]) 15 | 16 | 17 | class BvgBerlinPublicTransportBinarySensor(BvgBerlinPublicTransportEntity, BinarySensorEntity): 18 | """bvg_berlin_public_transport binary_sensor class.""" 19 | 20 | @property 21 | def name(self): 22 | """Return the name of the binary_sensor.""" 23 | return f"{DEFAULT_NAME}_{BINARY_SENSOR}" 24 | 25 | @property 26 | def device_class(self): 27 | """Return the class of this binary_sensor.""" 28 | return BINARY_SENSOR_DEVICE_CLASS 29 | 30 | @property 31 | def is_on(self): 32 | """Return true if the binary_sensor is on.""" 33 | return self.coordinator.data.get("title", "") == "foo" 34 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for BVG (Berlin Public Transport).""" 2 | import voluptuous as vol 3 | from homeassistant import config_entries 4 | from homeassistant.core import callback 5 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 6 | 7 | from .api import BvgBerlinPublicTransportApiClient 8 | from .const import CONF_PASSWORD 9 | from .const import CONF_USERNAME 10 | from .const import DOMAIN 11 | from .const import PLATFORMS 12 | 13 | 14 | class BvgBerlinPublicTransportFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 15 | """Config flow for bvg_berlin_public_transport.""" 16 | 17 | VERSION = 1 18 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 19 | 20 | def __init__(self): 21 | """Initialize.""" 22 | self._errors = {} 23 | 24 | async def async_step_user(self, user_input=None): 25 | """Handle a flow initialized by the user.""" 26 | self._errors = {} 27 | 28 | # Uncomment the next 2 lines if only a single instance of the integration is allowed: 29 | # if self._async_current_entries(): 30 | # return self.async_abort(reason="single_instance_allowed") 31 | 32 | if user_input is not None: 33 | valid = await self._test_credentials( 34 | user_input[CONF_USERNAME], user_input[CONF_PASSWORD] 35 | ) 36 | if valid: 37 | return self.async_create_entry( 38 | title=user_input[CONF_USERNAME], data=user_input 39 | ) 40 | else: 41 | self._errors["base"] = "auth" 42 | 43 | return await self._show_config_form(user_input) 44 | 45 | return await self._show_config_form(user_input) 46 | 47 | @staticmethod 48 | @callback 49 | def async_get_options_flow(config_entry): 50 | return BvgBerlinPublicTransportOptionsFlowHandler(config_entry) 51 | 52 | async def _show_config_form(self, user_input): # pylint: disable=unused-argument 53 | """Show the configuration form to edit location data.""" 54 | return self.async_show_form( 55 | step_id="user", 56 | data_schema=vol.Schema( 57 | {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} 58 | ), 59 | errors=self._errors, 60 | ) 61 | 62 | async def _test_credentials(self, username, password): 63 | """Return true if credentials is valid.""" 64 | try: 65 | session = async_create_clientsession(self.hass) 66 | client = BvgBerlinPublicTransportApiClient(username, password, session) 67 | await client.async_get_data() 68 | return True 69 | except Exception: # pylint: disable=broad-except 70 | pass 71 | return False 72 | 73 | 74 | class BvgBerlinPublicTransportOptionsFlowHandler(config_entries.OptionsFlow): 75 | """Config flow options handler for bvg_berlin_public_transport.""" 76 | 77 | def __init__(self, config_entry): 78 | """Initialize HACS options flow.""" 79 | self.config_entry = config_entry 80 | self.options = dict(config_entry.options) 81 | 82 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 83 | """Manage the options.""" 84 | return await self.async_step_user() 85 | 86 | async def async_step_user(self, user_input=None): 87 | """Handle a flow initialized by the user.""" 88 | if user_input is not None: 89 | self.options.update(user_input) 90 | return await self._update_options() 91 | 92 | return self.async_show_form( 93 | step_id="user", 94 | data_schema=vol.Schema( 95 | { 96 | vol.Required(x, default=self.options.get(x, True)): bool 97 | for x in sorted(PLATFORMS) 98 | } 99 | ), 100 | ) 101 | 102 | async def _update_options(self): 103 | """Update config entry options.""" 104 | return self.async_create_entry( 105 | title=self.config_entry.data.get(CONF_USERNAME), data=self.options 106 | ) 107 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/const.py: -------------------------------------------------------------------------------- 1 | """Constants for BVG (Berlin Public Transport).""" 2 | # Base component constants 3 | NAME = "BVG (Berlin Public Transport)" 4 | DOMAIN = "bvg_berlin_public_transport" 5 | DOMAIN_DATA = f"{DOMAIN}_data" 6 | VERSION = "0.0.0" 7 | 8 | ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" 9 | ISSUE_URL = "https://github.com/ryanbateman/bvg-berlin-public-transport/issues" 10 | 11 | # Icons 12 | ICON = "mdi:format-quote-close" 13 | 14 | # Device classes 15 | BINARY_SENSOR_DEVICE_CLASS = "connectivity" 16 | 17 | # Platforms 18 | BINARY_SENSOR = "binary_sensor" 19 | SENSOR = "sensor" 20 | SWITCH = "switch" 21 | PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] 22 | 23 | 24 | # Configuration and options 25 | CONF_ENABLED = "enabled" 26 | CONF_USERNAME = "username" 27 | CONF_PASSWORD = "password" 28 | 29 | # Defaults 30 | DEFAULT_NAME = DOMAIN 31 | 32 | 33 | STARTUP_MESSAGE = f""" 34 | ------------------------------------------------------------------- 35 | {NAME} 36 | Version: {VERSION} 37 | This is a custom integration! 38 | If you have any issues with this you need to open an issue here: 39 | {ISSUE_URL} 40 | ------------------------------------------------------------------- 41 | """ 42 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/entity.py: -------------------------------------------------------------------------------- 1 | """BvgBerlinPublicTransportEntity class""" 2 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 3 | 4 | from .const import ATTRIBUTION 5 | from .const import DOMAIN 6 | from .const import NAME 7 | from .const import VERSION 8 | 9 | 10 | class BvgBerlinPublicTransportEntity(CoordinatorEntity): 11 | def __init__(self, coordinator, config_entry): 12 | super().__init__(coordinator) 13 | self.config_entry = config_entry 14 | 15 | @property 16 | def unique_id(self): 17 | """Return a unique ID to use for this entity.""" 18 | return self.config_entry.entry_id 19 | 20 | @property 21 | def device_info(self): 22 | return { 23 | "identifiers": {(DOMAIN, self.unique_id)}, 24 | "name": NAME, 25 | "model": VERSION, 26 | "manufacturer": NAME, 27 | } 28 | 29 | @property 30 | def device_state_attributes(self): 31 | """Return the state attributes.""" 32 | return { 33 | "attribution": ATTRIBUTION, 34 | "id": str(self.coordinator.data.get("id")), 35 | "integration": DOMAIN, 36 | } 37 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "bvg_berlin_public_transport", 3 | "name": "BVG (Berlin Public Transport)", 4 | "documentation": "https://github.com/ryanbateman/bvg_hacs", 5 | "issue_tracker": "https://github.com/ryanbateman/bvg_hacs/issues", 6 | "dependencies": [], 7 | "config_flow": true, 8 | "codeowners": ["@ryanbateman"], 9 | "version": "0.0.1", 10 | "iot_class": "cloud_polling", 11 | "requirements": [] 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/sensor.py: -------------------------------------------------------------------------------- 1 | # Version History: 2 | # Version 0.1 - initial release 3 | # Version 0.2 - added multiple destinations, optimized error logging 4 | # Version 0.3 fixed encoding, simplified config for direction 5 | # Version 0.3.1 fixed a bug when departure is null 6 | # Version 0.3.2 bufix for TypeError 7 | # Version 0.3.3 switched to timezone aware objects, cache_size added to config parameters, optimized logging 8 | # Version 0.3.4 fixed encoding (issue #3), fixed typo in filepath 9 | 10 | from urllib.request import urlopen 11 | import json 12 | import pytz 13 | 14 | import os.path 15 | 16 | from datetime import datetime, timedelta 17 | from urllib.error import URLError 18 | 19 | import logging 20 | import voluptuous as vol 21 | from homeassistant.helpers.entity import Entity 22 | import homeassistant.helpers.config_validation as cv 23 | from homeassistant.components.sensor import PLATFORM_SCHEMA 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | ATTR_STOP_ID = 'stop_id' 28 | ATTR_STOP_NAME = 'stop_name' 29 | ATTR_DUE_IN = 'due_in' 30 | ATTR_DELAY = 'delay' 31 | ATTR_REAL_TIME = 'departure_time' 32 | ATTR_DESTINATION = 'direction' 33 | ATTR_TRANS_TYPE = 'type' 34 | ATTR_TRIP_ID = 'trip' 35 | ATTR_LINE_NAME = 'line_name' 36 | ATTR_CONNECTION_STATE = 'connection_status' 37 | 38 | CONF_NAME = 'name' 39 | CONF_STOP_ID = 'stop_id' 40 | CONF_DESTINATION = 'direction' 41 | CONF_MIN_DUE_IN = 'walking_distance' 42 | CONF_CACHE_PATH = 'file_path' 43 | CONF_CACHE_SIZE = 'cache_size' 44 | 45 | CONNECTION_STATE = 'connection_state' 46 | CON_STATE_ONLINE = 'online' 47 | CON_STATE_OFFLINE = 'offline' 48 | 49 | ICONS = { 50 | 'suburban': 'mdi:subway-variant', 51 | 'subway': 'mdi:subway', 52 | 'tram': 'mdi:tram', 53 | 'bus': 'mdi:bus', 54 | 'regional': 'mdi:train', 55 | 'ferry': 'mdi:ferry', 56 | 'express': 'mdi:train', 57 | 'n/a': 'mdi:clock', 58 | None: 'mdi:clock' 59 | } 60 | 61 | SCAN_INTERVAL = timedelta(seconds=60) 62 | 63 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 64 | vol.Required(CONF_STOP_ID): cv.string, 65 | vol.Required(CONF_DESTINATION): vol.All(cv.ensure_list, [cv.string]), 66 | vol.Optional(CONF_MIN_DUE_IN, default=10): cv.positive_int, 67 | vol.Optional(CONF_CACHE_PATH, default='/'): cv.string, 68 | vol.Optional(CONF_NAME, default='BVG'): cv.string, 69 | vol.Optional(CONF_CACHE_SIZE, default=90): cv.positive_int, 70 | }) 71 | 72 | 73 | def setup_platform(hass, config, add_entities, discovery_info=None): 74 | """Setup the sensor platform.""" 75 | stop_id = config[CONF_STOP_ID] 76 | direction = config.get(CONF_DESTINATION) 77 | min_due_in = config.get(CONF_MIN_DUE_IN) 78 | file_path = config.get(CONF_CACHE_PATH) 79 | name = config.get(CONF_NAME) 80 | cache_size = config.get(CONF_CACHE_SIZE) 81 | add_entities([Bvgsensor(name, stop_id, direction, min_due_in, file_path, hass, cache_size)]) 82 | 83 | 84 | class Bvgsensor(Entity): 85 | """Representation of a Sensor.""" 86 | 87 | def __init__(self, name, stop_id, direction, min_due_in, file_path, hass, cache_size): 88 | """Initialize the sensor.""" 89 | self.hass_config = hass.config.as_dict() 90 | self._cache_size = cache_size 91 | self._cache_creation_date = None 92 | self._isCacheValid = True 93 | self._timezone = self.hass_config.get("time_zone") 94 | self._name = name 95 | self._state = None 96 | self._stop_id = stop_id 97 | self.direction = direction 98 | self.min_due_in = min_due_in 99 | self.url = "https://v5.bvg.transport.rest/stops/{}/departures?duration={}".format(self._stop_id, self._cache_size) 100 | self.data = None 101 | self.singleConnection = None 102 | self._con_state = {CONNECTION_STATE: CON_STATE_ONLINE} 103 | 104 | @property 105 | def name(self): 106 | """Return the name of the sensor.""" 107 | return self._name 108 | 109 | @property 110 | def state(self): 111 | """Return the state of the sensor.""" 112 | return self._state 113 | 114 | @property 115 | def device_state_attributes(self): 116 | """Return the state attributes.""" 117 | if self.singleConnection is not None: 118 | return {ATTR_STOP_ID: self._stop_id, 119 | ATTR_STOP_NAME: self.singleConnection.get(ATTR_STOP_NAME), 120 | ATTR_DELAY: self.singleConnection.get(ATTR_DELAY), 121 | ATTR_REAL_TIME: self.singleConnection.get(ATTR_REAL_TIME), 122 | ATTR_DESTINATION: self.singleConnection.get(ATTR_DESTINATION), 123 | ATTR_TRANS_TYPE: self.singleConnection.get(ATTR_TRANS_TYPE), 124 | ATTR_LINE_NAME: self.singleConnection.get(ATTR_LINE_NAME) 125 | } 126 | else: 127 | return {ATTR_STOP_ID: 'n/a', 128 | ATTR_STOP_NAME: 'n/a', 129 | ATTR_DELAY: 'n/a', 130 | ATTR_REAL_TIME: 'n/a', 131 | ATTR_DESTINATION: 'n/a', 132 | ATTR_TRANS_TYPE: 'n/a', 133 | ATTR_LINE_NAME: 'n/a' 134 | } 135 | 136 | @property 137 | def unit_of_measurement(self): 138 | """Return the unit of measurement.""" 139 | return "min" 140 | 141 | @property 142 | def icon(self): 143 | """Icon to use in the frontend, if any.""" 144 | if self.singleConnection is not None: 145 | return ICONS.get(self.singleConnection.get(ATTR_TRANS_TYPE)) 146 | else: 147 | return ICONS.get(None) 148 | 149 | def update(self): 150 | """Fetch new state data for the sensor. 151 | This is the only method that should fetch new data for Home Assistant. 152 | """ 153 | self.fetchDataFromURL 154 | self.singleConnection = self.getSingleConnection(self.direction, self.min_due_in, 0) 155 | if self.singleConnection is not None and len(self.singleConnection) > 0: 156 | self._state = self.singleConnection.get(ATTR_DUE_IN) 157 | else: 158 | self._state = None 159 | 160 | # only custom code beyond this line 161 | @property 162 | def fetchDataFromURL(self): 163 | try: 164 | with urlopen(self.url) as response: 165 | source = response.read().decode('utf8') 166 | self.data = json.loads(source) 167 | if self._con_state.get(CONNECTION_STATE) is CON_STATE_OFFLINE: 168 | _LOGGER.warning("Connection to BVG API re-established") 169 | self._con_state.update({CONNECTION_STATE: CON_STATE_ONLINE}) 170 | self._state = None 171 | except URLError as e: 172 | if self._con_state.get(CONNECTION_STATE) is CON_STATE_ONLINE: 173 | _LOGGER.debug(e) 174 | _LOGGER.warning("Connection to BVG API lost, using local cache instead") 175 | self._con_state.update({CONNECTION_STATE: CON_STATE_OFFLINE}) 176 | self._state = None 177 | 178 | def getSingleConnection(self, direction, min_due_in, nmbr): 179 | timetable_l = list() 180 | date_now = datetime.now(pytz.timezone(self.hass_config.get("time_zone"))) 181 | for dest in direction: 182 | for pos in self.data: 183 | # _LOGGER.warning("conf_direction: {} pos_direction {}".format(direction, pos['direction'])) 184 | # if pos['direction'] in direction: 185 | if dest in pos['direction']: 186 | if pos['when'] is None: 187 | continue 188 | dep_time = datetime.strptime(pos['when'][:-6], "%Y-%m-%dT%H:%M:%S") 189 | dep_time = pytz.timezone('Europe/Berlin').localize(dep_time) 190 | delay = (pos['delay'] // 60) if pos['delay'] is not None else 0 191 | departure_td = dep_time - date_now 192 | # check if connection is not in the past 193 | if departure_td > timedelta(days=0): 194 | departure_td = (departure_td.seconds // 60) 195 | if departure_td >= min_due_in: 196 | timetable_l.append({ATTR_DESTINATION: pos['direction'], ATTR_REAL_TIME: dep_time, 197 | ATTR_DUE_IN: departure_td, ATTR_DELAY: delay, 198 | ATTR_TRIP_ID: pos['tripId'], ATTR_STOP_NAME: pos['stop']['name'], 199 | ATTR_TRANS_TYPE: pos['line']['product'], ATTR_LINE_NAME: pos['line']['name'] 200 | }) 201 | _LOGGER.debug("Connection found") 202 | else: 203 | _LOGGER.debug("Connection is due in under {} minutes".format(min_due_in)) 204 | else: 205 | _LOGGER.debug("Connection lies in the past") 206 | else: 207 | _LOGGER.debug("No connection for specified direction") 208 | try: 209 | _LOGGER.debug("Valid connection found") 210 | _LOGGER.debug("Connection: {}".format(timetable_l)) 211 | return(timetable_l[int(nmbr)]) 212 | except IndexError as e: 213 | return None 214 | 215 | 216 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for BVG (Berlin Public Transport).""" 2 | from homeassistant.components.switch import SwitchEntity 3 | 4 | from .const import DEFAULT_NAME 5 | from .const import DOMAIN 6 | from .const import ICON 7 | from .const import SWITCH 8 | from .entity import BvgBerlinPublicTransportEntity 9 | 10 | 11 | async def async_setup_entry(hass, entry, async_add_devices): 12 | """Setup sensor platform.""" 13 | coordinator = hass.data[DOMAIN][entry.entry_id] 14 | async_add_devices([BvgBerlinPublicTransportBinarySwitch(coordinator, entry)]) 15 | 16 | 17 | class BvgBerlinPublicTransportBinarySwitch(BvgBerlinPublicTransportEntity, SwitchEntity): 18 | """bvg_berlin_public_transport switch class.""" 19 | 20 | async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument 21 | """Turn on the switch.""" 22 | await self.coordinator.api.async_set_title("bar") 23 | await self.coordinator.async_request_refresh() 24 | 25 | async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument 26 | """Turn off the switch.""" 27 | await self.coordinator.api.async_set_title("foo") 28 | await self.coordinator.async_request_refresh() 29 | 30 | @property 31 | def name(self): 32 | """Return the name of the switch.""" 33 | return f"{DEFAULT_NAME}_{SWITCH}" 34 | 35 | @property 36 | def icon(self): 37 | """Return the icon of this switch.""" 38 | return ICON 39 | 40 | @property 41 | def is_on(self): 42 | """Return true if the switch is on.""" 43 | return self.coordinator.data.get("title", "") == "foo" 44 | -------------------------------------------------------------------------------- /custom_components/bvg_berlin_public_transport/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "If you need help with the configuration have a look here: https://github.com/ryanbateman/bvg-berlin-public-transport", 6 | "data": { 7 | "username": "Username", 8 | "password": "Password" 9 | } 10 | } 11 | }, 12 | "error": { 13 | "auth": "Username/Password is wrong." 14 | }, 15 | "abort": { 16 | "single_instance_allowed": "Only a single instance is allowed." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "user": { 22 | "data": { 23 | "binary_sensor": "Binary sensor enabled", 24 | "sensor": "Sensor enabled", 25 | "switch": "Switch enabled" 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryanbateman/bvg-sensor/957af5c17d15adc16250e3306b8bf0254cef5c88/example.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BVG (Berlin Public Transport)", 3 | "hacs": "1.6.0", 4 | "country": ["DE"], 5 | "homeassistant": "0.118.0" 6 | } 7 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | [![GitHub Release][releases-shield]][releases] 2 | [![GitHub Activity][commits-shield]][commits] 3 | [![License][license-shield]][license] 4 | 5 | [![hacs][hacsbadge]][hacs] 6 | [![Project Maintenance][maintenance-shield]][user_profile] 7 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 8 | 9 | **This component will set up the following platforms.** 10 | 11 | | Platform | Description | 12 | | --------------- | ----------------------------------- | 13 | | `binary_sensor` | Show something `True` or `False`. | 14 | | `sensor` | Show info from API. | 15 | | `switch` | Switch something `True` or `False`. | 16 | 17 | ![example][exampleimg] 18 | 19 | {% if not installed %} 20 | 21 | ## Installation 22 | 23 | 1. Click install. 24 | 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "BVG (Berlin Public Transport)". 25 | 26 | {% endif %} 27 | 28 | ## Configuration is done in the UI 29 | 30 | 31 | 32 | ## Credits 33 | 34 | This project was generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter](https://github.com/oncleben31/cookiecutter-homeassistant-custom-component) template. 35 | 36 | Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template 37 | 38 | --- 39 | 40 | [integration_blueprint]: https://github.com/custom-components/integration_blueprint 41 | [buymecoffee]: https://www.buymeacoffee.com/ludeeus 42 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 43 | [commits-shield]: https://img.shields.io/github/commit-activity/y/ryanbateman/bvg_hacs.svg?style=for-the-badge 44 | [commits]: https://github.com/ryanbateman/bvg_hacs/commits/main 45 | [hacs]: https://hacs.xyz 46 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 47 | [discord]: https://discord.gg/Qa5fW2R 48 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 49 | [exampleimg]: example.png 50 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 51 | [forum]: https://community.home-assistant.io/ 52 | [license]: https://github.com/ryanbateman/bvg_hacs/blob/main/LICENSE 53 | [license-shield]: https://img.shields.io/github/license/ryanbateman/bvg_hacs.svg?style=for-the-badge 54 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40ryanbateman-blue.svg?style=for-the-badge 55 | [releases-shield]: https://img.shields.io/github/release/ryanbateman/bvg_hacs.svg?style=for-the-badge 56 | [releases]: https://github.com/ryanbateman/bvg_hacs/releases 57 | [user_profile]: https://github.com/ryanbateman 58 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | # splits long import on multiple lines indented by 4 spaces 22 | multi_line_output = 3 23 | include_trailing_comma=True 24 | force_grid_wrap=0 25 | use_parentheses=True 26 | line_length=88 27 | indent = " " 28 | # by default isort don't check module indexes 29 | not_skip = __init__.py 30 | # will group `import x` and `from x import` of the same module. 31 | force_sort_within_sections = true 32 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 33 | default_section = THIRDPARTY 34 | known_first_party = custom_components.bvg_berlin_public_transport 35 | combine_as_imports = true 36 | 37 | [tool:pytest] 38 | addopts = -qq --cov=custom_components.bvg_berlin_public_transport 39 | console_output_style = count 40 | 41 | [coverage:run] 42 | branch = False 43 | 44 | [coverage:report] 45 | show_missing = true 46 | fail_under = 100 47 | --------------------------------------------------------------------------------