From e52c54d7cc1c1b9ebd54650d64587aef8f35744b Mon Sep 17 00:00:00 2001 From: Brett Date: Wed, 31 Jan 2024 08:29:15 +1000 Subject: [PATCH] Initial setup --- .devcontainer.json | 35 ++ .gitattributes | 1 + .github/ISSUE_TEMPLATE/bug.yml | 55 +++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 47 ++ .github/dependabot.yml | 15 + .github/workflows/lint.yml | 29 ++ .github/workflows/release.yml | 35 ++ .github/workflows/validate.yml | 37 ++ .gitignore | 17 + .ruff.toml | 48 ++ LICENSE | 21 + README.md | 48 ++ config/configuration.yaml | 8 + custom_components/teslemetry/__init__.py | 97 ++++ custom_components/teslemetry/climate.py | 130 +++++ custom_components/teslemetry/config_flow.py | 63 +++ custom_components/teslemetry/const.py | 31 ++ custom_components/teslemetry/context.py | 16 + custom_components/teslemetry/coordinator.py | 96 ++++ custom_components/teslemetry/entity.py | 127 +++++ custom_components/teslemetry/manifest.json | 11 + custom_components/teslemetry/models.py | 40 ++ custom_components/teslemetry/sensor.py | 443 ++++++++++++++++++ custom_components/teslemetry/strings.json | 157 +++++++ .../teslemetry/translations/en.json | 35 ++ hacs.json | 8 + requirements.txt | 4 + scripts/develop | 20 + scripts/lint | 7 + scripts/setup | 7 + 31 files changed, 1689 insertions(+) create mode 100644 .devcontainer.json create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 .ruff.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/configuration.yaml create mode 100644 custom_components/teslemetry/__init__.py create mode 100644 custom_components/teslemetry/climate.py create mode 100644 custom_components/teslemetry/config_flow.py create mode 100644 custom_components/teslemetry/const.py create mode 100644 custom_components/teslemetry/context.py create mode 100644 custom_components/teslemetry/coordinator.py create mode 100644 custom_components/teslemetry/entity.py create mode 100644 custom_components/teslemetry/manifest.json create mode 100644 custom_components/teslemetry/models.py create mode 100644 custom_components/teslemetry/sensor.py create mode 100644 custom_components/teslemetry/strings.json create mode 100644 custom_components/teslemetry/translations/en.json create mode 100644 hacs.json create mode 100644 requirements.txt create mode 100755 scripts/develop create mode 100755 scripts/lint create mode 100755 scripts/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..158002b --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "teslemetry/hacs-teslemetry", + "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "postCreateCommand": "scripts/setup", + "forwardPorts": [8123], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": ["ms-python.python", "github.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", "ms-python.vscode-pylance"], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..f43dca2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,55 @@ +--- +name: "Bug report" +description: "Report a bug with the integration" +labels: "Bug" +body: +- type: markdown + attributes: + value: Before you open a new issue, search through the existing issues to see if others have had the same problem. +- type: textarea + attributes: + label: "System Health details" + description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have enabled debug logging for my installation. + required: true + - label: I have filled out the issue template to the best of my ability. + required: true + - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). + required: true + - label: This issue is not a duplicate issue of any [previous issues](https://github.com/teslemetry/hacs-teslemetry/issues?q=is%3Aissue+label%3A%22Bug%22+).. + required: true +- type: textarea + attributes: + label: "Describe the issue" + description: "A clear and concise description of what the issue is." + validations: + required: true +- type: textarea + attributes: + label: Reproduction steps + description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." + value: | + 1. + 2. + 3. + ... + validations: + required: true +- type: textarea + attributes: + label: "Debug logs" + description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." + render: text + validations: + required: true + +- type: textarea + attributes: + label: "Diagnostics dump" + description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..43cbd32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +--- +name: "Feature request" +description: "Suggest an idea for this project" +labels: "Feature+Request" +body: +- type: markdown + attributes: + value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have filled out the template to the best of my ability. + required: true + - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). + required: true + - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/teslemetry/hacs-teslemetry/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). + required: true + +- type: textarea + attributes: + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is." + placeholder: "I'm always frustrated when [...]" + validations: + required: true + +- type: textarea + attributes: + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." + validations: + required: true + +- type: textarea + attributes: + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." + validations: + required: true + +- type: textarea + attributes: + label: "Additional context" + description: "Add any other context or screenshots about the feature request here." + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04f2d40 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + ignore: + # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json + - dependency-name: "homeassistant" \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..25bf6cc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: "Lint" + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + ruff: + name: "Ruff" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Set up Python" + uses: actions/setup-python@v4.7.1 + with: + python-version: "3.11" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Run" + run: python3 -m ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6fd6ca5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Adjust version number" + shell: "bash" + run: | + yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ + "${{ github.workspace }}/custom_components/teslemetry/manifest.json" + + - name: "ZIP the integration directory" + shell: "bash" + run: | + cd "${{ github.workspace }}/custom_components/teslemetry" + zip teslemetry.zip -r ./ + + - name: "Upload the ZIP file to the release" + uses: softprops/action-gh-release@v0.1.15 + with: + files: ${{ github.workspace }}/custom_components/teslemetry/teslemetry.zip diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3f643d1 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: "Validate" + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest + name: "Hassfest Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run hassfest validation" + uses: "home-assistant/actions/hassfest@master" + + hacs: # https://github.com/hacs/action + name: "HACS Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run HACS validation" + uses: "hacs/action@main" + with: + category: "integration" + # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands + ignore: "brands" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94fa16c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +.vscode +coverage.xml + + +# Home Assistant configuration +config/* +!config/configuration.yaml \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..7a8331a --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c4f993 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 - 2023 Brett Adams @Bre77 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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 OR COPYRIGHT HOLDERS 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9639092 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Teslemetry + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) + +![Project Maintenance][maintenance-shield] + +[![Discord][discord-shield]][discord] + +_Integration to integrate with [Teslemetry][hacs-teslemetry]._ + +**This integration will set up the following platforms.** + +Platform | Description +-- | -- +`binary_sensor` | Show something `True` or `False`. +`sensor` | Show info from blueprint API. +`switch` | Switch something `True` or `False`. + +## Installation + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +1. If you do not have a `custom_components` directory (folder) there, you need to create it. +1. In the `custom_components` directory (folder) create a new folder called `hacs-teslemetry`. +1. Download _all_ the files from the `custom_components/hacs-teslemetry/` directory (folder) in this repository. +1. Place the files you downloaded in the new directory (folder) you created. +1. Restart Home Assistant +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Integration blueprint" + +## Configuration is done in the UI + + + +## Contributions are welcome! + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +*** + +[commits-shield]: https://img.shields.io/github/commit-activity/y/Teslemetry/hacs-teslemetry.svg?style=for-the-badge +[commits]: https://github.com/teslemetry/hacs-teslemetry/commits/main +[discord]: https://discord.gg/7wZwHaZbWD +[discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge +[license-shield]: https://img.shields.io/github/license/teslemetry/hacs-teslemetry.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/teslemetry/hacs-teslemetry.svg?style=for-the-badge +[releases]: https://github.com/teslemetry/hacs-teslemetry/releases diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..832dea6 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,8 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.hacs-teslemetry: debug diff --git a/custom_components/teslemetry/__init__.py b/custom_components/teslemetry/__init__.py new file mode 100644 index 0000000..f2532fc --- /dev/null +++ b/custom_components/teslemetry/__init__.py @@ -0,0 +1,97 @@ +"""Teslemetry integration.""" +import asyncio +from typing import Final + +from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .coordinator import ( + TeslemetryEnergyDataCoordinator, + TeslemetryVehicleDataCoordinator, +) +from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData + +PLATFORMS: Final = [Platform.CLIMATE, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Teslemetry config.""" + + access_token = entry.data[CONF_ACCESS_TOKEN] + + # Create API connection + teslemetry = Teslemetry( + session=async_get_clientsession(hass), + access_token=access_token, + ) + try: + products = (await teslemetry.products())["response"] + except InvalidToken: + LOGGER.error("Access token is invalid, unable to connect to Teslemetry") + return False + except PaymentRequired: + LOGGER.error("Subscription required, unable to connect to Telemetry") + return False + except TeslaFleetError as e: + raise ConfigEntryNotReady from e + + # Create array of classes + vehicles: list[TeslemetryVehicleData] = [] + energysites: list[TeslemetryEnergyData] = [] + for product in products: + if "vin" in product: + vin = product["vin"] + api = VehicleSpecific(teslemetry.vehicle, vin) + coordinator = TeslemetryVehicleDataCoordinator(hass, api) + vehicles.append( + TeslemetryVehicleData( + api=api, + coordinator=coordinator, + vin=vin, + ) + ) + elif "energy_site_id" in product: + site_id = product["energy_site_id"] + api = EnergySpecific(teslemetry.energy, site_id) + energysites.append( + TeslemetryEnergyData( + api=api, + coordinator=TeslemetryEnergyDataCoordinator(hass, api), + id=site_id, + info=product, + ) + ) + + # Do all coordinator first refreshes simultaneously + await asyncio.gather( + *( + vehicle.coordinator.async_config_entry_first_refresh() + for vehicle in vehicles + ), + *( + energysite.coordinator.async_config_entry_first_refresh() + for energysite in energysites + ), + ) + + # Setup Platforms + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = TeslemetryData( + vehicles, energysites + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Teslemetry Config.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/custom_components/teslemetry/climate.py b/custom_components/teslemetry/climate.py new file mode 100644 index 0000000..b7d2ab2 --- /dev/null +++ b/custom_components/teslemetry/climate.py @@ -0,0 +1,130 @@ +"""Climate platform for Teslemetry integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TeslemetryClimateSide +from .context import handle_command +from .entity import TeslemetryVehicleEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry Climate platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER) + for vehicle in data.vehicles + ) + + +class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): + """Vehicle Location Climate Class.""" + + _attr_precision = PRECISION_HALVES + _attr_min_temp = 15 + _attr_max_temp = 28 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes = ["off", "keep", "dog", "camp"] + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation ie. heat, cool mode.""" + if self.get("climate_state_is_climate_on"): + return HVACMode.HEAT_COOL + return HVACMode.OFF + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get("climate_state_inside_temp") + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.get(f"climate_state_{self.key}_setting") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.get("climate_state_max_avail_temp", self._attr_max_temp) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.get("climate_state_min_avail_temp", self._attr_min_temp) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.get("climate_state_climate_keeper_mode") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.auto_conditioning_start() + self.set(("climate_state_is_climate_on", True)) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.auto_conditioning_stop() + self.set( + ("climate_state_is_climate_on", False), + ("climate_state_climate_keeper_mode", "off"), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + temp = kwargs[ATTR_TEMPERATURE] + with handle_command(): + await self.wake_up_if_asleep() + await self.api.set_temps( + driver_temp=temp, + passenger_temp=temp, + ) + + self.set((f"climate_state_{self.key}_setting", temp)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the climate preset mode.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.set_climate_keeper_mode( + climate_keeper_mode=self._attr_preset_modes.index(preset_mode) + ) + self.set( + ( + "climate_state_climate_keeper_mode", + preset_mode, + ), + ( + "climate_state_is_climate_on", + preset_mode != self._attr_preset_modes[0], + ), + ) diff --git a/custom_components/teslemetry/config_flow.py b/custom_components/teslemetry/config_flow.py new file mode 100644 index 0000000..64a2791 --- /dev/null +++ b/custom_components/teslemetry/config_flow.py @@ -0,0 +1,63 @@ +"""Config Flow for Teslemetry integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import ClientConnectionError +from tesla_fleet_api import Teslemetry +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +TESLEMETRY_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DESCRIPTION_PLACEHOLDERS = { + "short_url": "teslemetry.com/console", + "url": "[teslemetry.com/console](https://teslemetry.com/console)", +} + + +class TeslemetryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Teslemetry API connection.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get configuration from the user.""" + errors: dict[str, str] = {} + if user_input: + teslemetry = Teslemetry( + session=async_get_clientsession(self.hass), + access_token=user_input[CONF_ACCESS_TOKEN], + ) + try: + await teslemetry.test() + except InvalidToken: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + except PaymentRequired: + errors["base"] = "subscription_required" + except ClientConnectionError: + errors["base"] = "cannot_connect" + except TeslaFleetError as e: + LOGGER.exception(str(e)) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Teslemetry", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESLEMETRY_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + errors=errors, + ) diff --git a/custom_components/teslemetry/const.py b/custom_components/teslemetry/const.py new file mode 100644 index 0000000..9b31a32 --- /dev/null +++ b/custom_components/teslemetry/const.py @@ -0,0 +1,31 @@ +"""Constants used by Teslemetry integration.""" +from __future__ import annotations + +from enum import StrEnum +import logging + +DOMAIN = "teslemetry" + +LOGGER = logging.getLogger(__package__) + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TeslemetryState(StrEnum): + """Teslemetry Vehicle States.""" + + ONLINE = "online" + ASLEEP = "asleep" + OFFLINE = "offline" + + +class TeslemetryClimateSide(StrEnum): + """Teslemetry Climate Keeper Modes.""" + + DRIVER = "driver_temp" + PASSENGER = "passenger_temp" diff --git a/custom_components/teslemetry/context.py b/custom_components/teslemetry/context.py new file mode 100644 index 0000000..942f1cc --- /dev/null +++ b/custom_components/teslemetry/context.py @@ -0,0 +1,16 @@ +"""Teslemetry context managers.""" + +from contextlib import contextmanager + +from tesla_fleet_api.exceptions import TeslaFleetError + +from homeassistant.exceptions import HomeAssistantError + + +@contextmanager +def handle_command(): + """Handle wake up and errors.""" + try: + yield + except TeslaFleetError as e: + raise HomeAssistantError("Teslemetry command failed") from e diff --git a/custom_components/teslemetry/coordinator.py b/custom_components/teslemetry/coordinator.py new file mode 100644 index 0000000..feac2e6 --- /dev/null +++ b/custom_components/teslemetry/coordinator.py @@ -0,0 +1,96 @@ +"""Teslemetry Data Coordinator.""" +from datetime import timedelta +from typing import Any + +from tesla_fleet_api import EnergySpecific, VehicleSpecific +from tesla_fleet_api.exceptions import TeslaFleetError, VehicleOffline + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, TeslemetryState + +SYNC_INTERVAL = 60 + + +class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Teslemetry API.""" + + def __init__(self, hass: HomeAssistant, api: VehicleSpecific) -> None: + """Initialize Teslemetry Vehicle Update Coordinator.""" + super().__init__( + hass, + LOGGER, + name="Teslemetry Vehicle", + update_interval=timedelta(seconds=SYNC_INTERVAL), + ) + self.api = api + + async def async_config_entry_first_refresh(self) -> None: + """Perform first refresh.""" + try: + response = await self.api.wake_up() + if response["response"]["state"] != TeslemetryState.ONLINE: + # The first refresh will fail, so retry later + raise ConfigEntryNotReady("Vehicle is not online") + except TeslaFleetError as e: + # The first refresh will also fail, so retry later + raise ConfigEntryNotReady from e + await super().async_config_entry_first_refresh() + + async def _async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Teslemetry API.""" + + try: + data = await self.api.vehicle_data() + except VehicleOffline: + self.data["state"] = TeslemetryState.OFFLINE + return self.data + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + return self._flatten(data["response"]) + + def _flatten( + self, data: dict[str, Any], parent: str | None = None + ) -> dict[str, Any]: + """Flatten the data structure.""" + result = {} + for key, value in data.items(): + if parent: + key = f"{parent}_{key}" + if isinstance(value, dict): + result.update(self._flatten(value, key)) + else: + result[key] = value + return result + + +class TeslemetryEnergyDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Teslemetry API.""" + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Teslemetry Energy Update Coordinator.""" + super().__init__( + hass, + LOGGER, + name="Teslemetry Energy Site", + update_interval=timedelta(seconds=SYNC_INTERVAL), + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site data using Teslemetry API.""" + + try: + data = await self.api.live_status() + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + # Convert Wall Connectors from array to dict + data["response"]["wall_connectors"] = { + wc["din"]: wc for wc in data["response"].get("wall_connectors", []) + } + + return data["response"] diff --git a/custom_components/teslemetry/entity.py b/custom_components/teslemetry/entity.py new file mode 100644 index 0000000..4ac59e7 --- /dev/null +++ b/custom_components/teslemetry/entity.py @@ -0,0 +1,127 @@ +"""Teslemetry parent entity class.""" + +import asyncio +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS, TeslemetryState +from .coordinator import ( + TeslemetryEnergyDataCoordinator, + TeslemetryVehicleDataCoordinator, +) +from .models import TeslemetryEnergyData, TeslemetryVehicleData + + +class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator]): + """Parent class for Teslemetry Vehicle Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + vehicle: TeslemetryVehicleData, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + super().__init__(vehicle.coordinator) + self.key = key + self.api = vehicle.api + self._wakelock = vehicle.wakelock + + car_type = self.coordinator.data["vehicle_config_car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{vehicle.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer="Tesla", + configuration_url="https://teslemetry.com/console", + name=self.coordinator.data["vehicle_state_vehicle_name"], + model=MODELS.get(car_type, car_type), + sw_version=self.coordinator.data["vehicle_state_car_version"].split(" ")[0], + hw_version=self.coordinator.data["vehicle_config_driver_assist"], + serial_number=vehicle.vin, + ) + + async def wake_up_if_asleep(self) -> None: + """Wake up the vehicle if its asleep.""" + async with self._wakelock: + while self.coordinator.data["state"] != TeslemetryState.ONLINE: + state = (await self.api.wake_up())["response"]["state"] + self.coordinator.data["state"] = state + if state != TeslemetryState.ONLINE: + await asyncio.sleep(5) + + def get(self, key: str | None = None, default: Any | None = None) -> Any: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key or self.key, default) + + def set(self, *args: Any) -> None: + """Set a value in coordinator data.""" + for key, value in args: + self.coordinator.data[key] = value + self.async_write_ha_state() + + +class TeslemetryEnergyEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]): + """Parent class for Teslemetry Energy Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + energysite: TeslemetryEnergyData, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + super().__init__(energysite.coordinator) + self.key = key + self.api = energysite.api + + self._attr_translation_key = key + self._attr_unique_id = f"{energysite.id}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(energysite.id))}, + manufacturer="Tesla", + configuration_url="https://teslemetry.com/console", + name=self.coordinator.data.get("site_name", "Energy Site"), + ) + + def get(self, key: str | None = None, default: Any | None = None) -> Any: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key or self.key, default) + + +class TeslemetryWallConnectorEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]): + """Parent class for Teslemetry Wall Connector Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + energysite: TeslemetryEnergyData, + din: str, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + super().__init__(energysite.coordinator) + self.din = din + self.key = key + + self._attr_translation_key = key + self._attr_unique_id = f"{energysite.id}-{din}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, din)}, + manufacturer="Tesla", + configuration_url="https://teslemetry.com/console", + name="Wall Connector", + via_device=(DOMAIN, str(energysite.id)), + serial_number=din.split("-")[-1], + ) + + @property + def _value(self) -> int: + """Return a specific wall connector value from coordinator data.""" + return self.coordinator.data["wall_connectors"][self.din].get(self.key) diff --git a/custom_components/teslemetry/manifest.json b/custom_components/teslemetry/manifest.json new file mode 100644 index 0000000..2d5172b --- /dev/null +++ b/custom_components/teslemetry/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "teslemetry", + "name": "Teslemetry", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/teslemetry", + "iot_class": "cloud_polling", + "loggers": ["tesla-fleet-api"], + "requirements": ["tesla-fleet-api==0.2.3"], + "version": "1.0.0" +} diff --git a/custom_components/teslemetry/models.py b/custom_components/teslemetry/models.py new file mode 100644 index 0000000..2b41adf --- /dev/null +++ b/custom_components/teslemetry/models.py @@ -0,0 +1,40 @@ +"""The Teslemetry integration models.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from tesla_fleet_api import EnergySpecific, VehicleSpecific + +from .coordinator import ( + TeslemetryEnergyDataCoordinator, + TeslemetryVehicleDataCoordinator, +) + + +@dataclass +class TeslemetryData: + """Data for the Teslemetry integration.""" + + vehicles: list[TeslemetryVehicleData] + energysites: list[TeslemetryEnergyData] + + +@dataclass +class TeslemetryVehicleData: + """Data for a vehicle in the Teslemetry integration.""" + + api: VehicleSpecific + coordinator: TeslemetryVehicleDataCoordinator + vin: str + wakelock = asyncio.Lock() + + +@dataclass +class TeslemetryEnergyData: + """Data for a vehicle in the Teslemetry integration.""" + + api: EnergySpecific + coordinator: TeslemetryEnergyDataCoordinator + id: int + info: dict[str, str] diff --git a/custom_components/teslemetry/sensor.py b/custom_components/teslemetry/sensor.py new file mode 100644 index 0000000..e4cd17d --- /dev/null +++ b/custom_components/teslemetry/sensor.py @@ -0,0 +1,443 @@ +"""Sensor platform for Teslemetry integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .entity import ( + TeslemetryEnergyEntity, + TeslemetryVehicleEntity, + TeslemetryWallConnectorEntity, +) +from .models import TeslemetryEnergyData, TeslemetryVehicleData + + +@callback +def minutes_to_datetime(value: StateType) -> datetime | None: + """Convert relative hours into absolute datetime.""" + if isinstance(value, (int, float)) and value > 0: + return dt_util.now() + timedelta(minutes=value) + return None + + +@dataclass(frozen=True, kw_only=True) +class TeslemetrySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType | datetime] = lambda x: x + + +VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( + TeslemetrySensorEntityDescription( + key="charge_state_usable_battery_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + TeslemetrySensorEntityDescription( + key="charge_state_charge_energy_added", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + ), + TeslemetrySensorEntityDescription( + key="charge_state_charger_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + TeslemetrySensorEntityDescription( + key="charge_state_charger_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="charge_state_charger_actual_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="charge_state_charge_rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="charge_state_minutes_to_full_charge", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=minutes_to_datetime, + ), + TeslemetrySensorEntityDescription( + key="charge_state_battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + ), + TeslemetrySensorEntityDescription( + key="drive_state_speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + ), + TeslemetrySensorEntityDescription( + key="drive_state_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="drive_state_shift_state", + icon="mdi:car-shift-pattern", + options=["p", "d", "r", "n"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda x: x.lower() if isinstance(x, str) else x, + ), + TeslemetrySensorEntityDescription( + key="vehicle_state_odometer", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="vehicle_state_tpms_pressure_fl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="vehicle_state_tpms_pressure_fr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="vehicle_state_tpms_pressure_rl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="vehicle_state_tpms_pressure_rr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="climate_state_inside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TeslemetrySensorEntityDescription( + key="climate_state_outside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TeslemetrySensorEntityDescription( + key="climate_state_driver_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="climate_state_passenger_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="drive_state_active_route_traffic_minutes_delay", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + ), + TeslemetrySensorEntityDescription( + key="drive_state_active_route_energy_at_arrival", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetrySensorEntityDescription( + key="drive_state_active_route_miles_to_arrival", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + ), + TeslemetrySensorEntityDescription( + key="drive_state_active_route_minutes_to_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=minutes_to_datetime, + ), + TeslemetrySensorEntityDescription( + key="drive_state_active_route_destination", + icon="mdi:map-marker", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +ENERGY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="solar_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + icon="mdi:solar-power", + ), + SensorEntityDescription( + key="energy_left", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:battery", + ), + SensorEntityDescription( + key="total_pack_energy", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:battery-high", + ), + SensorEntityDescription( + key="percentage_charged", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="battery_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + icon="mdi:home-battery", + ), + SensorEntityDescription( + key="load_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + icon="mdi:power-plug", + ), + SensorEntityDescription( + key="grid_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + icon="mdi:transmission-tower", + ), + SensorEntityDescription( + key="grid_services_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + icon="mdi:transmission-tower", + ), + SensorEntityDescription( + key="generator_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + icon="mdi:generator-stationary", + ), +) + +WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="wall_connector_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + icon="mdi:ev-station", + ), + SensorEntityDescription( + key="wall_connector_fault_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + icon="mdi:ev-station", + ), + SensorEntityDescription( + key="wall_connector_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + icon="mdi:ev-station", + ), + SensorEntityDescription( + key="vin", + icon="mdi:car-electric", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + # Add vehicles + async_add_entities( + TeslemetryVehicleSensorEntity(vehicle, description) + for vehicle in data.vehicles + for description in VEHICLE_DESCRIPTIONS + ) + + # Add energy sites + async_add_entities( + TeslemetryEnergySensorEntity(energysite, description) + for energysite in data.energysites + for description in ENERGY_DESCRIPTIONS + if description.key in energysite.coordinator.data + ) + + # Add wall connectors + async_add_entities( + TeslemetryWallConnectorSensorEntity(energysite, din, description) + for energysite in data.energysites + for din in energysite.coordinator.data.get("wall_connectors", {}) + for description in WALL_CONNECTOR_DESCRIPTIONS + ) + + +class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): + """Base class for Teslemetry vehicle metric sensors.""" + + entity_description: TeslemetrySensorEntityDescription + + def __init__( + self, + vehicle: TeslemetryVehicleData, + description: TeslemetrySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(vehicle, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.get()) + + +class TeslemetryEnergySensorEntity(TeslemetryEnergyEntity, SensorEntity): + """Base class for Teslemetry energy site metric sensors.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + energysite: TeslemetryEnergyData, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(energysite, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.get() + + +class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): + """Base class for Teslemetry energy site metric sensors.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + energysite: TeslemetryEnergyData, + din: str, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__( + energysite, + din, + description.key, + ) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self._value diff --git a/custom_components/teslemetry/strings.json b/custom_components/teslemetry/strings.json new file mode 100644 index 0000000..28bbca5 --- /dev/null +++ b/custom_components/teslemetry/strings.json @@ -0,0 +1,157 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "subscription_required": "Subscription required, please visit {short_url}", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Enter an access token from {url}." + } + } + }, + "entity": { + "climate": { + "driver_temp": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "keep": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + }, + "sensor": { + "charge_state_usable_battery_level": { + "name": "Battery level" + }, + "charge_state_charge_energy_added": { + "name": "Charge energy added" + }, + "charge_state_charger_power": { + "name": "Charger power" + }, + "charge_state_charger_voltage": { + "name": "Charger voltage" + }, + "charge_state_charger_actual_current": { + "name": "Charger current" + }, + "charge_state_charge_rate": { + "name": "Charge rate" + }, + "charge_state_battery_range": { + "name": "Battery range" + }, + "charge_state_minutes_to_full_charge": { + "name": "Time to full charge" + }, + "drive_state_speed": { + "name": "Speed" + }, + "drive_state_power": { + "name": "Power" + }, + "drive_state_shift_state": { + "name": "Shift state", + "state": { + "p": "Park", + "d": "Drive", + "r": "Reverse", + "n": "Neutral" + } + }, + "vehicle_state_odometer": { + "name": "Odometer" + }, + "vehicle_state_tpms_pressure_fl": { + "name": "Tire pressure front left" + }, + "vehicle_state_tpms_pressure_fr": { + "name": "Tire pressure front right" + }, + "vehicle_state_tpms_pressure_rl": { + "name": "Tire pressure rear left" + }, + "vehicle_state_tpms_pressure_rr": { + "name": "Tire pressure rear right" + }, + "climate_state_inside_temp": { + "name": "Inside temperature" + }, + "climate_state_outside_temp": { + "name": "Outside temperature" + }, + "climate_state_driver_temp_setting": { + "name": "Driver temperature setting" + }, + "climate_state_passenger_temp_setting": { + "name": "Passenger temperature setting" + }, + "drive_state_active_route_traffic_minutes_delay": { + "name": "Traffic delay" + }, + "drive_state_active_route_energy_at_arrival": { + "name": "State of charge at arrival" + }, + "drive_state_active_route_miles_to_arrival": { + "name": "Distance to arrival" + }, + "drive_state_active_route_minutes_to_arrival": { + "name": "Time to arrival" + }, + "drive_state_active_route_destination": { + "name": "Destination" + }, + "solar_power": { + "name": "Solar power" + }, + "energy_left": { + "name": "Energy left" + }, + "total_pack_energy": { + "name": "Total pack energy" + }, + "percentage_charged": { + "name": "Percentage charged" + }, + "battery_power": { + "name": "Battery power" + }, + "load_power": { + "name": "Load power" + }, + "grid_power": { + "name": "Grid power" + }, + "grid_services_power": { + "name": "Grid services power" + }, + "generator_power": { + "name": "Generator power" + }, + "wall_connector_state": { + "name": "State" + }, + "wall_connector_fault_state": { + "name": "Fault state" + }, + "wall_connector_power": { + "name": "Power" + }, + "vin": { + "name": "Vehicle" + } + } + } +} diff --git a/custom_components/teslemetry/translations/en.json b/custom_components/teslemetry/translations/en.json new file mode 100644 index 0000000..a841d1c --- /dev/null +++ b/custom_components/teslemetry/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_access_token": "Invalid access token", + "subscription_required": "Subscription required, please visit {short_url}", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "access_token": "Access token" + }, + "description": "Enter an access token from {url}." + } + } + }, + "entity": { + "climate": { + "driver_temp": { + "name": "Climate", + "state_attributes": { + "preset_mode": { + "state": { + "camp": "Camp mode", + "dog": "Dog mode", + "keep": "Keep mode", + "off": "Normal" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..955b298 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "Teslemetry", + "filename": "teslemetry.zip", + "hide_default_branch": true, + "homeassistant": "2024.1.0", + "render_readme": true, + "zip_release": true +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..96d2c84 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.7.0 +homeassistant==2023.8.0 +pip>=21.0,<23.2 +ruff==0.0.292 diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..98f4e0c --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/hacs-teslemetry +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..9b5b1df --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..141d19f --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt