arraylabs / pymyq

Python wrapper for MyQ API
MIT License
112 stars 42 forks source link

Garage door state stuck at closing or opening #143

Closed HuidaeCho closed 2 years ago

HuidaeCho commented 2 years ago

This SO question is related.

I have two versions of almost the same code: (1) one using Bottle as a uWSGI web app, (2) another as a command line utility. The web app only works the first time after it's loaded and it fails with a "Session is closed" error thereafter. It reports the door state as "opening" or "closing" when the door is fully open or closed already. The command line program reports the correct state and works every time without any issues.

The web app used to work fine with pymyq 3.0.3 before I updated pymyq to 3.1.4 this morning.

I tested the same code with pymyq 3.0.3 again. Well, it works now again.

This is the core part of the app:

def do(command):
    print("command:", command)
    asyncio.get_event_loop().run_until_complete(do_myq(command))
    print("completed")

async def do_myq(command) -> None:
    async with ClientSession() as websession:
        try:
            api = await login(myq_user, myq_pass, websession)
            door = api.devices[next(iter(api.covers.keys()))]
            print("state:", door.state)
            if command == "toggle":
                if door.state == "closed":
                    print("task: open")
                    state = await door.open(wait_for_state=False)
                else:
                    print("task: close")
                    state = await door.close(wait_for_state=False)
            elif command == "open" and door.state == "closed":
                print("task: open")
                state = await door.open(wait_for_state=False)
            elif command == "close" and door.state == "open":
                print("task: close")
                state = await door.close(wait_for_state=False)
            else:
                state = None
            if state is not None:
                print("new state:", state, door.state)
        except Exception as e:
            traceback.print_exc(file=sys.stdout)

Actually, pymyq 3.0.3 also seems to raise the same "Session is closed" exception at login(), but Task exception was never retrieved (not sure what it means and what it's for). With pymyq 3.1.4, the same exception is raised when door.close() is called.

  1. Why is a fresh session closed?
  2. How can pymyq 3.0.3 work when the session is closed?
  3. Why doesn't it happen with the command line code?

With pymyq 3.1.4:

command: close
state: opening
task: close
Traceback (most recent call last):
  File "garagedoor.py", line 86, in do_myq
    state = await door.close(wait_for_state=False)
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/garagedoor.py", line 66, in close
    return await self._send_state_command(
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/device.py", line 190, in _send_state_command
    await self._wait_for_state_task
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/device.py", line 261, in wait_for_state
    await self._account.update()
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/account.py", line 194, in update
    await self._get_devices()
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/account.py", line 99, in _get_devices
    _, devices_resp = await self._api.request(
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/api.py", line 250, in request
    return await call_method(
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/request.py", line 244, in request_json
    resp = await self._send_request(
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/request.py", line 141, in _send_request
    resp = await websession.request(
  File "/home/user/usr/local/lib/python3.9/site-packages/aiohttp/client.py", line 399, in _request
    raise RuntimeError("Session is closed")
RuntimeError: Session is closed

With pymyq 3.0.3:

command: close
Task exception was never retrieved
future: <Task finished name='Task-51' coro=<MyQDevice.wait_for_state() done, defined at /home/user/usr/local/lib/python3.9/site-packages/pymyq/device.py:123> exception=RuntimeError('Session is closed')>
Traceback (most recent call last):
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/device.py", line 141, in wait_for_state
    await self._api.update_device_info(for_account=self.account)
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/api.py", line 634, in update_device_info
    await self._get_devices_for_account(account=account)
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/api.py", line 513, in _get_devices_for_account
    _, devices_resp = await self.request(
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/api.py", line 215, in request
    return await call_method(
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/request.py", line 101, in request_json
    resp = await self._send_request(
  File "/home/user/usr/local/lib/python3.9/site-packages/pymyq/request.py", line 51, in _send_request
    resp = await websession.request(
  File "/home/user/usr/local/lib/python3.9/site-packages/aiohttp/client.py", line 399, in _request
    raise RuntimeError("Session is closed")
RuntimeError: Session is closed
state: open
task: close
new state: <Task pending name='Task-68' coro=<MyQDevice.wait_for_state() running at /home/user/usr/local/lib/python3.9/site-packages/pymyq/device.py:123>> closing
completed
glyph commented 2 years ago

I'm getting the same error when trying to make a little FastAPI service, but a CLI tool running the exact same code works reliably. It seems to me that there is clearly some global state pollution here. I haven't tracked it down yet, but I do find it odd that the internals of pymyq freely intermix calls to async with ClientSession() as websession with just accepting a websession parameter; in the absence of any countervailing explanation my current hypothesis is that somewhere the parameter is accidentally used as the contextmanager instead.

glyph commented 2 years ago

Here's the problem:

https://github.com/arraylabs/pymyq/blob/43325de7616c3433399bdaa64520ced3dca57f22/pymyq/account.py#L32

Specifically:

devices: dict = {}

This means you get one dictionary for the entire process, shared between every MyQAccount process.

I'll send a PR.