Skip to content

Commit

Permalink
Merge pull request #19 from eras/fix-16
Browse files Browse the repository at this point in the history
Fix #10, #15, #16, #17.
  • Loading branch information
eras committed Mar 8, 2023
2 parents cc3b7a9 + 01145ab commit b40d269
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 45 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
python -m pip install --upgrade pip
python -m pip install mypy wheel --requirement requirements.txt --requirement requirements-slack.txt --requirement requirements-matrix.txt
- name: Install dependencies for mypy checking
run: pip3 install -r requirements-types.txt

- name: Run mypy
run: mypy -p teslabot -p tests

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ You can use e.g. `screen`, `tmux` or `systemd` to arrange this process to run on
Note that by default you need to prefix commands with ```!```.

| command | description |
| --- | --- |
|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
| help | Show the list of commands supported. |
| authorize | Start the authorization flow. Works only in admin room (though you could only have one and same for control and admin). |
| authorize url | Last phase of the authorization flow. |
Expand Down Expand Up @@ -168,3 +168,5 @@ Note that by default you need to prefix commands with ```!```.
| charge schedule disable | Disable charging schedule. |
| heater seat n off/low/medium/high | Adjust seat heaters. Works only if AC is on. |
| heater steering off/high | Adjust steering wheel heater. Works only if AC is on. |
| command # comment | Run command; ignore # comment |
| # comment | Ignore message |
1 change: 1 addition & 0 deletions requirements-types.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
types-requests==2.28.11.15
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ typing-extensions==4.1.1
google-cloud-secret-manager==2.10.0
google-cloud-firestore==2.4.0
setuptools==65.5.1
urllib3==1.26.14
8 changes: 7 additions & 1 deletion teslabot/appscheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import time
from typing import List, Tuple, Optional, Any, Callable, Awaitable, TypeVar, Generic
from dataclasses import dataclass
import traceback

from . import scheduler
from . import log
Expand Down Expand Up @@ -300,5 +301,10 @@ async def _activate_timer(self, entry: scheduler.Entry[SchedulerContext]) -> Non
await self.control.send_message(context.to_message_context(), f"Timer activated: \"{' '.join(command)}\"")
assert self._commands
invocation = c.Invocation(name=command[0], args=command[1:])
await self._commands.invoke(context, invocation)
try:
await self._commands.invoke(context, invocation)
except Exception as exn:
logger.error(f"{context.txn} {exn} {traceback.format_exc()}")
await self.control.send_message(context.to_message_context(),
f"{context.txn} Exception :(")
await self._command_ls(context, ())
8 changes: 5 additions & 3 deletions teslabot/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from abc import ABC, abstractmethod
from typing import List, Callable, Coroutine, Any, TypeVar, Generic, Optional, Tuple, Mapping, Union, Awaitable
from .parser import Parser, ParseResult, ParseOK, ParseFail
from .utils import assert_some

from . import log

Expand All @@ -20,7 +21,7 @@ class CommandsException(Exception):
class ParseError(CommandsException):
pass

class InvocationParseError(ParseError):
class InvocationEmptyError(ParseError):
pass

@dataclass
Expand All @@ -45,13 +46,14 @@ class Invocation:

@staticmethod
def parse(message: str) -> "Invocation":
fields = re.split(r" *", message)
command = assert_some(re.match(r"^[^#]*", message), "This should always match")[0] # extract non-comment part
fields = [field for field in re.split(r" *", command) if field != ""]
if len(fields):
logger.debug(f"Command: {fields}")
return Invocation(name=fields[0],
args=fields[1:])
else:
raise InvocationParseError()
raise InvocationEmptyError()

class Command(ABC, Generic[Context]):
name: str
Expand Down
2 changes: 2 additions & 0 deletions teslabot/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ async def process_message(self, command_context: CommandContext, message: str) -
await self.local_commands.invoke(command_context, invocation)
else:
await self.callback.command_callback(command_context, invocation)
except commands.InvocationEmptyError as exn:
logger.debug("Ignoring empty message (or completely commented)")
except commands.CommandParseError as exn:
logger.error(f"{command_context.txn}: Failed to parse command: {message}")
def format(word: str, highlight: bool) -> str:
Expand Down
129 changes: 89 additions & 40 deletions teslabot/tesla.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import teslapy
from urllib.error import HTTPError
from urllib3.exceptions import ProtocolError
from requests.exceptions import ConnectionError

from .control import Control, ControlCallback, CommandContext, MessageContext
from .commands import Invocation
Expand Down Expand Up @@ -47,15 +49,16 @@ class VehicleException(AppException):
VehicleName = NewType('VehicleName', str)

class ValidVehicle(p.Map[str, VehicleName]):
tesla: teslapy.Tesla
app: "App"

def __init__(self, tesla: teslapy.Tesla) -> None:
def __init__(self, app: "App") -> None:
super().__init__(map=lambda x: VehicleName(x),
parser=p.Delayed[str](self.make_validator))
self.tesla = tesla
self.app = app

def make_validator(self) -> p.Parser[str]:
vehicles = self.tesla.vehicle_list()
# TODO: Cannot do async stuff here, so the cached version must do
vehicles = self.app.cached_vehicle_list
display_names = [vehicle["display_name"] for vehicle in vehicles]
return p.OneOfStrings(display_names)

Expand Down Expand Up @@ -141,17 +144,17 @@ async def save(self, state: State) -> None:

ClimateArgs = Tuple[Tuple[bool, Optional[VehicleName]], Tuple[()]]
def valid_on_off_vehicle(app: "App") -> p.Parser[ClimateArgs]:
return p.Adjacent(p.Adjacent(p.Bool(), p.ValidOrMissing(ValidVehicle(app.tesla))),
return p.Adjacent(p.Adjacent(p.Bool(), p.ValidOrMissing(ValidVehicle(app))),
p.Empty())

InfoArgs = Tuple[Optional[VehicleName], Tuple[()]]
def valid_info(app: "App") -> p.Parser[InfoArgs]:
return p.Adjacent(p.ValidOrMissing(ValidVehicle(app.tesla)),
return p.Adjacent(p.ValidOrMissing(ValidVehicle(app)),
p.Empty())

LockUnlockArgs = Tuple[Optional[VehicleName], Tuple[()]]
def valid_lock_unlock(app: "App") -> p.Parser[LockUnlockArgs]:
return p.Adjacent(p.ValidOrMissing(ValidVehicle(app.tesla)),
return p.Adjacent(p.ValidOrMissing(ValidVehicle(app)),
p.Empty())

ChargeArgs = Tuple[Tuple[ChargeOp, Optional[VehicleName]], Tuple[()]]
Expand All @@ -176,7 +179,7 @@ def valid_charge(app: "App") -> p.Parser[ChargeArgs]:
p.Map(parser=p.Keyword("schedule", p.HhMm()),
map=lambda x: ChargeOpSchedulingEnable(x[0] * 60 + x[1])),
),
p.ValidOrMissing(ValidVehicle(app.tesla))),
p.ValidOrMissing(ValidVehicle(app))),
p.Empty()
)

Expand Down Expand Up @@ -233,14 +236,14 @@ def valid_heater(app: "App") -> p.Parser[HeaterArgs]:
map=lambda _: HeaterSteering())),
p.OneOfEnumValue(HeaterLevel)
),
p.ValidOrMissing(ValidVehicle(app.tesla))
p.ValidOrMissing(ValidVehicle(app))
),
p.Empty())

ShareArgs = Tuple[Tuple[str, Optional[VehicleName]], Tuple[()]]
def valid_share(app: "App") -> p.Parser[ShareArgs]:
return p.Adjacent(p.Adjacent(p.Concat(),
p.ValidOrMissing(ValidVehicle(app.tesla))),
p.ValidOrMissing(ValidVehicle(app))),
p.Empty())

def cmd_adjacent(label: str, parser: p.Parser[T]) -> p.Parser[Tuple[str, T]]:
Expand Down Expand Up @@ -282,6 +285,7 @@ class App(ControlCallback):
_scheduler: AppScheduler[None]
locations: Locations
location_detail: LocationDetail
cached_vehicle_list: List[Any]

def __init__(self,
control: Control,
Expand All @@ -291,6 +295,7 @@ def __init__(self,
self.state = env.state
self.locations = Locations(self.state)
self.location_detail = LocationDetail.Full
self.cached_vehicle_list = []
control.callback = self
cache_loader: Union[Callable[[], Dict[str, Any]], None] = None
cache_dumper: Union[Callable[[Dict[str, Any]], None], None] = None
Expand Down Expand Up @@ -369,9 +374,10 @@ async def _command_logout(self, context: CommandContext, args: Tuple[()]) -> Non
elif not context.admin_room:
await self.control.send_message(context.to_message_context(), "Please use the admin room for this command.")
else:
# https://github.com/python/mypy/issues/9590
def call() -> None:
self.tesla.logout()
await to_async(call)
await self._retry_to_async(call)
await self.control.send_message(context.to_message_context(), "Logout successful!")

async def command_callback(self,
Expand Down Expand Up @@ -454,20 +460,27 @@ async def _command_authorized(self, context: CommandContext, authorization_respo
# https://github.com/python/mypy/issues/9590
def call() -> None:
self.tesla.fetch_token(authorization_response=authorization_response)
await to_async(call)
await self._retry_to_async(call)
await self.control.send_message(context.to_message_context(), "Authorization successful")
vehicles = self.tesla.vehicle_list()
await self.control.send_message(context.to_message_context(), str(vehicles[0]))
elif not self.tesla.authorized:
await self.control.send_message(context.to_message_context(), f"Not authorized. Authorization URL: {self.tesla.authorization_url()} \"Page Not Found\" will be shown at success. Use !authorize https://the/url/you/ended/up/at")

async def _get_vehicle_list(self) -> List[Any]:
def call() -> List[Any]:
self.cached_vehicle_list = self.tesla.vehicle_list()
return self.cached_vehicle_list
result_or_error = await self._retry_to_async(call)
if isinstance(result_or_error, Exception):
raise result_or_error
assert result_or_error is not None
return result_or_error

async def _command_vehicles(self, context: CommandContext, valid: Tuple[()]) -> None:
vehicles = self.tesla.vehicle_list()
vehicles = self._get_vehicle_list()
await self.control.send_message(context.to_message_context(), f"vehicles: {vehicles}")

async def _get_vehicle(self, display_name: Optional[str]) -> teslapy.Vehicle:
vehicles = self.tesla.vehicle_list()
vehicles = await self._get_vehicle_list()
if display_name is not None:
vehicles = [vehicle for vehicle in vehicles if vehicle["display_name"].lower() == display_name.lower()]
if len(vehicles) > 1:
Expand Down Expand Up @@ -547,6 +560,9 @@ def call(vehicle: teslapy.Vehicle) -> Any:
data = await self._command_on_vehicle(context, vehicle_name, call, show_success=False)
if not data:
return
# refresh cache for parsers etc
await self._get_vehicle_list()

logger.debug(f"data: {data}")
dist_hr_unit = data["gui_settings"]["gui_distance_units"]
dist_unit = assert_some(re.match(r"^[^/]*", dist_hr_unit), "Expected to find / from dist_hr_unit")[0]
Expand Down Expand Up @@ -661,22 +677,16 @@ def call(vehicle: teslapy.Vehicle) -> Any:
return vehicle.command(command, **kwargs)
await self._command_on_vehicle(context, vehicle_name, call)

async def _command_on_vehicle(self,
context: CommandContext,
vehicle_name: Optional[str],
fn: Callable[[teslapy.Vehicle], T],
show_success: bool = True) -> Optional[T]:
vehicle = await self._get_vehicle(vehicle_name)
await self._wake(context, vehicle)
async def _retry(self,
fn: Callable[[], Awaitable[T]]) -> T:
num_retries = 0
result_is_set = False
result: T
error = None
result: Optional[T] = None
while num_retries < 5:
while num_retries < 10:
try:
# https://github.com/python/mypy/issues/9590
def call() -> Any:
return fn(vehicle)
result = await to_async(call)
result = await fn()
result_is_set = True
error = None
break
except teslapy.VehicleError as exn:
Expand All @@ -687,21 +697,57 @@ def call() -> Any:
except HTTPError as exn:
logger.debug(f"HTTP error: {exn}")
error = exn
except ProtocolError as exn:
logger.debug(f"HTTP protocol error: {exn}")
error = exn
except ConnectionError as exn:
logger.debug(f"HTTP connection error: {exn}")
error = exn
finally:
logger.debug(f"Done sending")
logger.debug(f"Retry round complete")
await asyncio.sleep(pow(1.15, num_retries) * 2)
num_retries += 1
if error:
await self.control.send_message(context.to_message_context(), f"Error: {error}")
if num_retries > 0:
logger.debug(f"Number of retries: {num_retries}")
if error is not None:
raise error
assert result_is_set
return result

async def _retry_to_async(self, fn: Callable[[], T]) -> T:
async def call() -> T:
def call2() -> T:
return fn()
return await to_async(call2)
return await self._retry(call)

async def _command_on_vehicle(self,
context: CommandContext,
vehicle_name: Optional[str],
fn: Callable[[teslapy.Vehicle], T],
show_success: bool = True) -> Optional[T]:
result: Optional[T] = None
try:
vehicle = await self._get_vehicle(vehicle_name)
await self._retry(lambda: self._wake(context, vehicle))
# https://github.com/python/mypy/issues/9590
def call() -> T:
return fn(vehicle)
result = await self._retry_to_async(call)
except Exception as exn:
if isinstance(exn, teslapy.VehicleError):
await self.control.send_message(context.to_message_context(), f"Error: {exn}")
else:
logger.error(f"{context.txn} {exn} {traceback.format_exc()}")
await self.control.send_message(context.to_message_context(),
f"{context.txn} Exception :(")
return None
else:
assert result is not None
if show_success:
message = "Success!"
if result != True: # this never happens, though?
message += f" {result}"
await self.control.send_message(context.to_message_context(), message)
return result
if show_success:
message = "Success!"
if result != True: # this never happens, though?
message += f" {result}"
await self.control.send_message(context.to_message_context(), message)
return result

async def _command_climate(self, context: CommandContext, args: ClimateArgs) -> None:
(mode, vehicle_name), _ = args
Expand All @@ -727,3 +773,6 @@ async def run(self) -> None:

if not self.tesla.authorized:
await self.control.send_message(MessageContext(admin_room=True), f"Not authorized. Authorization URL: {self.tesla.authorization_url()} \"Page Not Found\" will be shown at success. Use !authorize https://the/url/you/ended/up/at")
else:
# ensure the vehicle list is cached at least once
await self._get_vehicle_list()

0 comments on commit b40d269

Please sign in to comment.