Skip to content

Commit 5c4f87d

Browse files
authored
Add more TeslaFi commands [part of #28] (#35)
* Raise API errors * Add charge port door cover This is part of #28 * Add TURN_ON/TURN_OFF ClimateEntityFeatures These were added in 2024.2, so they need to be recreated. This adds to the fix for #30, which implemented the callbacks, but did not add the supported feature. * Add steering wheel heater switch * Add charging configs Adds charger SOC setting, and charger current * Fix SOC number unit NumberEntity does not support .unit_of_measurement, requires using .native_unit_of_measurement (%). * Add start/stop charge switch [part of #28] (#36) * Add start/stop charge switch This switch can take the place of the binary_sensor.charging entity, where the switch can be toggled off/on in order to stop/start charging. The command(s) must be enabled in TeslaFi for this to work, and the way the entity appears in Home Assistant might be less desirable for some - that is why the entity is disabled by default. If you want to keep the binary_sensor, but also still have the ability to stop/start charging, you can set the switch entity to hidden, and use some other means to toggle the switch, like with a Button or automation calling switch.* services. * Set charging state optimistically
1 parent 309467d commit 5c4f87d

File tree

10 files changed

+338
-18
lines changed

10 files changed

+338
-18
lines changed

custom_components/teslafi/__init__.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""TeslaFi integration."""
2+
23
from __future__ import annotations
34
from homeassistant.config_entries import ConfigEntry
45
from homeassistant.const import Platform, CONF_API_KEY
@@ -19,12 +20,13 @@
1920
Platform.BINARY_SENSOR,
2021
Platform.BUTTON,
2122
Platform.CLIMATE,
22-
# TODO: Platform.COVER,
23+
Platform.COVER,
2324
Platform.DEVICE_TRACKER,
2425
Platform.LOCK,
26+
Platform.NUMBER,
2527
# Platform.SELECT,
2628
Platform.SENSOR,
27-
# TODO: Platform.SWITCH,
29+
Platform.SWITCH,
2830
Platform.UPDATE,
2931
]
3032

custom_components/teslafi/base.py

+46
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
from collections.abc import Callable
44
from dataclasses import dataclass
5+
from numbers import Number
56
from typing import Generic, TypeVar, cast
67
from typing_extensions import override
78
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
89
from homeassistant.components.button import ButtonEntityDescription
910
from homeassistant.components.climate import ClimateEntityDescription
11+
from homeassistant.components.cover import CoverEntityDescription
1012
from homeassistant.components.lock import LockEntityDescription
13+
from homeassistant.components.number import NumberEntityDescription
1114
from homeassistant.components.sensor import SensorEntityDescription
15+
from homeassistant.components.switch import SwitchEntityDescription
1216
from homeassistant.components.update import UpdateEntityDescription
1317
from homeassistant.core import HomeAssistant
1418
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
@@ -122,6 +126,18 @@ class TeslaFiClimateEntityDescription(
122126
"""TeslaFi Climate EntityDescription"""
123127

124128

129+
@dataclass
130+
class TeslaFiCoverEntityDescription(
131+
CoverEntityDescription,
132+
TeslaFiBaseEntityDescription,
133+
):
134+
"""TeslaFi Cover"""
135+
136+
value: Callable[[TeslaFiVehicle, HomeAssistant], bool] = None
137+
convert: Callable[[any], bool] = _convert_to_bool
138+
cmd: Callable[[TeslaFiCoordinator, bool], dict] = None
139+
140+
125141
@dataclass
126142
class TeslaFiSensorEntityDescription(
127143
SensorEntityDescription,
@@ -133,6 +149,36 @@ class TeslaFiSensorEntityDescription(
133149
"""Dictionary of state -> icon"""
134150

135151

152+
@dataclass
153+
class TeslaFiNumberEntityDescription(
154+
NumberEntityDescription,
155+
TeslaFiBaseEntityDescription,
156+
):
157+
"""TeslaFi Number EntityDescription"""
158+
159+
convert: Callable[[any], bool] = lambda v: int(v) if v else None
160+
cmd: Callable[[TeslaFiCoordinator, Number], dict] = None
161+
162+
max_value_key: str = None
163+
"""
164+
If specified, look up this key for the max value,
165+
otherwise fall back to max_value.
166+
"""
167+
168+
169+
@dataclass(slots=True)
170+
class TeslaFiSwitchEntityDescription(
171+
SwitchEntityDescription,
172+
TeslaFiBaseEntityDescription,
173+
):
174+
"""TeslaFi Switch EntityDescription"""
175+
176+
cmd: Callable[[TeslaFiCoordinator, bool], bool] = None
177+
"""The command to send to TeslaFi on toggle."""
178+
179+
convert: Callable[[any], bool] = _convert_to_bool
180+
181+
136182
@dataclass
137183
class TeslaFiUpdateEntityDescription(
138184
UpdateEntityDescription,

custom_components/teslafi/binary_sensor.py

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
SENSORS = [
1919
# region Charging
2020
TeslaFiBinarySensorEntityDescription(
21+
# TODO: convert to switch?
2122
key="_is_charging",
2223
name="Charging",
2324
icon="mdi:ev-station",

custom_components/teslafi/client.py

+22-7
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""TeslaFi API Client"""
22

33
from json import JSONDecodeError
4-
import httpx
4+
from httpx import AsyncClient, Response
55
import logging
66

7-
from .errors import VehicleNotReadyError
7+
from .errors import TeslaFiApiError, VehicleNotReadyError
88
from .model import TeslaFiVehicle
99

1010
REQUEST_TIMEOUT = 5
@@ -15,12 +15,12 @@ class TeslaFiClient:
1515
"""TeslaFi API Client"""
1616

1717
_api_key: str
18-
_client: httpx.AsyncClient
18+
_client: AsyncClient
1919

2020
def __init__(
2121
self,
2222
api_key: str,
23-
client: httpx.AsyncClient,
23+
client: AsyncClient,
2424
) -> None:
2525
"""
2626
Creates a new TeslaFi API Client.
@@ -47,13 +47,20 @@ async def _request(self, command: str = "", **kwargs) -> dict:
4747
"""
4848
:param command: The command to send. Can be empty string, `lastGood`, etc. See
4949
"""
50+
_LOGGER.debug(">> executing command %s; args=%s", command, kwargs)
5051
timeout = kwargs.get("wake", 0) + REQUEST_TIMEOUT
51-
response = await self._client.get(
52+
response: Response = await self._client.get(
5253
url="https://www.teslafi.com/feed.php",
5354
headers={"Authorization": "Bearer " + self._api_key},
5455
params={"command": command} | kwargs,
5556
timeout=timeout,
5657
)
58+
_LOGGER.debug(
59+
"<< command %s response[%d]: %s",
60+
command,
61+
response.status_code,
62+
response.text,
63+
)
5764
assert response.status_code < 400
5865

5966
try:
@@ -68,10 +75,18 @@ async def _request(self, command: str = "", **kwargs) -> dict:
6875

6976
if isinstance(data, dict):
7077
if err := data.get("error"):
71-
raise RuntimeError(f"{err}: {data.get('error_description')}")
72-
if data.get("response", {}).get("result", None) == "unauthorized":
78+
raise TeslaFiApiError(f"{err}: {data.get('error_description')}")
79+
response: dict = data.get("response", {})
80+
if response.get("result") == "unauthorized":
7381
raise PermissionError(
7482
f"TeslaFi response unauthorized for api key {self._api_key}: {data}"
7583
)
84+
if not response.get("result", True):
85+
msg = (
86+
response.get("reason")
87+
or response.get("string")
88+
or f"Unexpected response: {data}"
89+
)
90+
raise TeslaFiApiError(msg)
7691

7792
return data

custom_components/teslafi/climate.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ class TeslaFiClimate(TeslaFiEntity[TeslaFiClimateEntityDescription], ClimateEnti
5959
_attr_hvac_modes = [HVACMode.AUTO, HVACMode.OFF]
6060
_attr_temperature_unit = UnitOfTemperature.CELSIUS
6161
_attr_supported_features = ClimateEntityFeature(
62-
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
62+
ClimateEntityFeature.TARGET_TEMPERATURE
63+
| ClimateEntityFeature.PRESET_MODE
64+
# FIXME: min 2024.2
65+
| ClimateEntityFeature(128)
66+
| ClimateEntityFeature(256)
6367
)
6468

6569
_attr_fan_modes = [FAN_AUTO, FAN_OFF]

custom_components/teslafi/coordinator.py

+2-8
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
POLLING_INTERVAL_DRIVING,
1515
POLLING_INTERVAL_SLEEPING,
1616
)
17-
from .errors import VehicleNotReadyError
1817
from .model import TeslaFiVehicle
1918

2019

@@ -48,13 +47,8 @@ async def execute_command(self, cmd: str, **kwargs) -> dict:
4847
"""Execute the remote command."""
4948
if self.data.is_sleeping:
5049
kwargs["wake"] = DELAY_CMD_WAKE.seconds
51-
LOGGER.debug(">> executing command %s; args=%s", cmd, kwargs)
52-
try:
53-
result = await self._client.command(cmd, **kwargs)
54-
except VehicleNotReadyError as e:
55-
LOGGER.warning(f"Failed sending command to vehicle: {e}")
56-
LOGGER.debug("<< command %s response: %s", cmd, result)
57-
return result
50+
51+
return await self._client.command(cmd, **kwargs)
5852

5953
def schedule_refresh_in(self, delta: timedelta):
6054
"""Attempt to schedule a refresh"""

custom_components/teslafi/cover.py

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from typing import Any
2+
from homeassistant.components.cover import (
3+
CoverDeviceClass,
4+
CoverEntity,
5+
CoverEntityFeature,
6+
)
7+
from homeassistant.config_entries import ConfigEntry
8+
from homeassistant.const import EntityCategory
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
11+
12+
from .base import TeslaFiEntity, TeslaFiCoverEntityDescription
13+
from .const import DOMAIN, LOGGER
14+
from .coordinator import TeslaFiCoordinator
15+
from .errors import TeslaFiApiError
16+
from .util import _convert_to_bool
17+
18+
COVERS = [
19+
TeslaFiCoverEntityDescription(
20+
key="charge_port_door_open",
21+
name="Charge Port Door",
22+
device_class=CoverDeviceClass.DOOR,
23+
icon="mdi:ev-plug-tesla",
24+
value=lambda d, h: d.get("charge_port_door_open", False),
25+
available=lambda u, d, h: u and d.car_state != "driving",
26+
cmd=lambda c, v: c.execute_command(
27+
"charge_port_door_open" if v else "charge_port_door_close"
28+
),
29+
)
30+
]
31+
32+
33+
class TeslaFiCoverEntity(
34+
TeslaFiEntity[TeslaFiCoverEntityDescription],
35+
CoverEntity,
36+
):
37+
"""TeslaFi Cover Entity"""
38+
39+
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
40+
_attr_is_closed = False
41+
_attr_is_closing = False
42+
_attr_is_opening = False
43+
44+
def _handle_coordinator_update(self) -> None:
45+
self._attr_is_closed = self._get_value() == False
46+
self._attr_is_opening = False
47+
self._attr_is_closing = False
48+
return super()._handle_coordinator_update()
49+
50+
async def async_close_cover(self, **kwargs: Any):
51+
if self.coordinator.data.is_plugged_in:
52+
LOGGER.warning("Cannot close charge port door while plugged in!")
53+
54+
try:
55+
self._attr_is_opening = False
56+
await self.entity_description.cmd(self.coordinator, False)
57+
self._attr_is_closed = True
58+
except TeslaFiApiError as e:
59+
if "already closed" not in str(e):
60+
raise e
61+
self._attr_is_closed = True
62+
return self.async_write_ha_state()
63+
64+
async def async_open_cover(self, **kwargs: Any) -> None:
65+
if self.coordinator.data.shift_state != "park":
66+
LOGGER.warning("Cannot open charge port door while driving!")
67+
try:
68+
self._attr_is_closing = False
69+
await self.entity_description.cmd(self.coordinator, True)
70+
self._attr_is_closed = False
71+
except TeslaFiApiError as e:
72+
if "already open" not in str(e):
73+
raise e
74+
self._attr_is_closed = False
75+
return self.async_write_ha_state()
76+
77+
78+
async def async_setup_entry(
79+
hass: HomeAssistant,
80+
config_entry: ConfigEntry,
81+
async_add_entities: AddEntitiesCallback,
82+
) -> None:
83+
"""Set up from config entry"""
84+
coordinator: TeslaFiCoordinator
85+
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
86+
entities: list[TeslaFiCoverEntity] = []
87+
entities.extend(
88+
[TeslaFiCoverEntity(coordinator, description) for description in COVERS]
89+
)
90+
async_add_entities(entities)

custom_components/teslafi/errors.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
1+
from dataclasses import dataclass
2+
3+
14
class VehicleNotReadyError(Exception):
25
"""The vehicle is sleeping"""
6+
7+
8+
class TeslaFiApiError(Exception):
9+
"""API responded with an error reason"""

0 commit comments

Comments
 (0)