pax_global_header00006660000000000000000000000064152525265560014527gustar00rootroot0000000000000052 comment=30a9ed69e341a39d2a1b7eaeeb9584b39d75aca8 Danielhiversen-pyOpenGarage-30a9ed6/000077500000000000000000000000001525252655600174635ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/.github/000077500000000000000000000000001525252655600210235ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/.github/FUNDING.yml000066400000000000000000000001461525252655600226410ustar00rootroot00000000000000# These are supported funding model platforms github: Danielhiversen custom: http://paypal.me/dahoiv Danielhiversen-pyOpenGarage-30a9ed6/.github/workflows/000077500000000000000000000000001525252655600230605ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/.github/workflows/black.yaml000066400000000000000000000024111525252655600250160ustar00rootroot00000000000000name: Black Code Formatter on: push: pull_request: schedule: - cron: "0 0 * * *" jobs: validate: runs-on: "ubuntu-latest" steps: - uses: actions/checkout@v7 - name: black if: github.event.pull_request.head.repo.full_name == github.repository uses: lgeiger/black-action@master with: args: . - name: Check for modified files if: github.event.pull_request.head.repo.full_name == github.repository id: git-check run: echo ::set-output name=modified::$(if git diff-index --quiet HEAD --; then echo "false"; else echo "true"; fi) - name: Push changes if: github.event.pull_request.head.repo.full_name == github.repository && steps.git-check.outputs.modified == 'true' run: | git config --global user.name 'Daniel Hoyer' git config --global user.email 'mail@dahoiv.net' git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY git checkout $GITHUB_HEAD_REF git commit -am "fixup! Format Python code with black" git push - name: Flake8 Code Linter uses: jonasrk/flake8-action@master with: args: "--max-line-length=120 " Danielhiversen-pyOpenGarage-30a9ed6/.github/workflows/code_checker.yml000066400000000000000000000017571525252655600262130ustar00rootroot00000000000000name: Code checker on: push: pull_request: schedule: - cron: "0 4 * * *" jobs: validate: runs-on: "ubuntu-latest" strategy: matrix: python-version: - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v6 with: path: ~/.cache/pip key: pip_cache - name: Install dependency run: | pip install '.[dev]' - name: Flake8 Code Linter run: | flake8 opengarage/ --max-line-length=120 --extend-ignore=DUO102,DUO130,S303,S311 - name: isort run: | isort **/*.py - name: Pylint Code Linter run: | pylint opengarage - name: Pytest run: | pytest -v Danielhiversen-pyOpenGarage-30a9ed6/.github/workflows/python-publish.yml000066400000000000000000000015151525252655600265720ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* Danielhiversen-pyOpenGarage-30a9ed6/.gitignore000066400000000000000000000022631525252655600214560ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ Danielhiversen-pyOpenGarage-30a9ed6/LICENSE000066400000000000000000000020661525252655600204740ustar00rootroot00000000000000MIT License Copyright (c) 2019 Daniel Høyer Iversen 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. Danielhiversen-pyOpenGarage-30a9ed6/README.md000066400000000000000000000000601525252655600207360ustar00rootroot00000000000000# pyOpenGarage Python library for opengarage.io Danielhiversen-pyOpenGarage-30a9ed6/manual_test.py000066400000000000000000000140561525252655600223570ustar00rootroot00000000000000"""Exercise a real OpenGarage device end-to-end. Read-only checks (state, capabilities) always run. Pass --actions to also toggle light/lock (only if the device reports support for them). Pass --door to additionally cycle the door open/close -- this physically moves your garage door, so only use it if that's safe to do right now. Not part of the test suite -- for interactive verification only. """ import argparse import asyncio from opengarage import OpenGarage # Fields that naturally fluctuate between polls and aren't part of the # device's controllable state, so they're excluded from before/after diffs. _VOLATILE_FIELDS = {"rcnt", "dist", "rssi"} def _diff_raw(before, after): keys = (set(before) | set(after)) - _VOLATILE_FIELDS return {k: (before.get(k), after.get(k)) for k in keys if before.get(k) != after.get(k)} async def check_state(client): print("--- state ---") state = await client.get_state() print("door_state:", state.door_state) print("capabilities:", state.capabilities) print("raw:", state.raw) return state async def _toggle_and_verify(client, action_name, toggle_fn, expected_field): before = (await client.get_state()).raw result = await toggle_fn() after = (await client.get_state()).raw diff = _diff_raw(before, after) print(f"{action_name} result: {result}, changed fields: {diff}") unexpected = set(diff) - {expected_field} if unexpected: print(f" WARNING: unexpected fields changed: {unexpected}") if expected_field not in diff: print(f" WARNING: expected '{expected_field}' to change, but it didn't") return after async def check_actions(client, state): print("--- actions ---") original = state.raw if state.capabilities.get("light_control"): await _toggle_and_verify(client, "toggle_light", client.toggle_light, "light") else: print("toggle_light: not supported, skipping") if state.capabilities.get("lock_control"): await _toggle_and_verify(client, "toggle_lock", client.toggle_lock, "lock") else: print("toggle_lock: not supported, skipping") print("--- reverting toggles ---") if state.capabilities.get("lock_control"): await _toggle_and_verify(client, "toggle_lock (revert)", client.toggle_lock, "lock") if state.capabilities.get("light_control"): await _toggle_and_verify(client, "toggle_light (revert)", client.toggle_light, "light") final = (await client.get_state()).raw diff = _diff_raw(original, final) if diff: print(f" WARNING: state did not fully revert to original: {diff}") else: print(" confirmed: state matches original after reverting toggles") async def _wait_for_door_value(client, expected_value, timeout=30, interval=1): """Poll until the door reaches expected_value, recording every state seen along the way.""" elapsed = 0 state = await client.get_state() observed = [state.door_state] while state.door_value != expected_value and elapsed < timeout: await asyncio.sleep(interval) elapsed += interval state = await client.get_state() observed.append(state.door_state) return state, observed async def check_door(client): print("--- door cycle ---") before = (await client.get_state()).raw print("push_open_button result:", await client.push_open_button()) opened, observed_opening = await _wait_for_door_value(client, 1) diff = _diff_raw(before, opened.raw) print(f"door_state after open: {opened.door_state}, changed fields: {diff}") print(f" observed states while opening: {observed_opening}") if "opening" not in observed_opening: print(" NOTE: never observed 'opening' -- door may move faster than the poll interval") # nopenings increments on open (secv=2 devices); not a bug, just expected. unexpected = set(diff) - {"door", "nopenings"} if unexpected: print(f" WARNING: unexpected fields changed: {unexpected}") if opened.door_value != 1: print(" WARNING: door did not reach 'open' within timeout") print("push_close_button result:", await client.push_close_button()) closed, observed_closing = await _wait_for_door_value(client, 0) diff = _diff_raw(opened.raw, closed.raw) print(f"door_state after close: {closed.door_state}, changed fields: {diff}") print(f" observed states while closing: {observed_closing}") if "closing" not in observed_closing: print(" NOTE: never observed 'closing' -- door may move faster than the poll interval") unexpected = set(diff) - {"door"} if unexpected: print(f" WARNING: unexpected fields changed: {unexpected}") if closed.door_value != 0: print(" WARNING: door did not reach 'closed' within timeout") # nopenings is cumulative and won't revert -- excluded from the final check. final_diff = {k: v for k, v in _diff_raw(before, closed.raw).items() if k != "nopenings"} if final_diff: print(f" WARNING: state did not fully revert to original: {final_diff}") else: print(" confirmed: door state matches original after open/close cycle") async def main(devip, devkey, run_actions, run_door): client = OpenGarage(devip, devkey) # no websession passed try: state = await check_state(client) if run_actions: await check_actions(client, state) if run_door: await check_door(client) finally: await client.close_connection() if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("devip", help="Device IP/host, e.g. 192.168.1.5 or http://192.168.1.5") parser.add_argument("devkey", help="Device key (default is 'opendoor')") parser.add_argument( "--actions", action="store_true", help="Also toggle light/lock (if supported)" ) parser.add_argument( "--door", action="store_true", help="Also cycle the door open/close (moves the door!)" ) parsed_args = parser.parse_args() asyncio.run(main(parsed_args.devip, parsed_args.devkey, parsed_args.actions, parsed_args.door)) Danielhiversen-pyOpenGarage-30a9ed6/opengarage/000077500000000000000000000000001525252655600215735ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/opengarage/__init__.py000066400000000000000000000156651525252655600237210ustar00rootroot00000000000000"""Open garage""" import asyncio import inspect import logging import aiohttp import async_timeout from opengarage.dispatcher import CommandDispatcher from opengarage.errors import (ResponseError, TransportError, UnsupportedFeatureError) from opengarage.state import normalize_state DEFAULT_TIMEOUT = 10 _LOGGER = logging.getLogger(__name__) class OpenGarage: """Class to communicate with the Open Garage api.""" # pylint: disable=too-many-arguments def __init__( self, devip, devkey, verify_ssl=False, websession=None, timeout=DEFAULT_TIMEOUT, ): """Initialize the Open Garage connection.""" self.websession = websession self._timeout = timeout self._devip = self._normalize_devip(devip) self._devkey = devkey self._verify_ssl = verify_ssl self._light_lock = None @staticmethod def _normalize_devip(devip): """Accept a bare host/IP or a full http(s) URL, defaulting to http://.""" devip = devip.rstrip("/") if not devip.startswith(("http://", "https://")): devip = f"http://{devip}" return devip @property def device_url(self): """Device url.""" return self._devip async def _ensure_session(self): """Create the websession on first use if one wasn't provided.""" if self.websession is None: connector = aiohttp.TCPConnector(ssl=self._verify_ssl) self.websession = aiohttp.ClientSession(connector=connector) async def close_connection(self): """Close the connection, if one was ever created.""" if self.websession is None: return await self.websession.close() async def update_state(self): """Update state (raw jc payload).""" return await self._execute("jc") async def get_state(self): """Get normalized state plus raw payload.""" raw = await self._execute("jc", wrap_errors=True) return normalize_state(raw) async def get_capabilities(self): """Return capability flags from normalized state.""" state = await self.get_state() return dict(state.capabilities) async def push_button(self): """Push button.""" return await self._dispatch_action("click") async def push_close_button(self): """Push close button. No-op if already closed.""" return await self._dispatch_action("close") async def push_open_button(self): """Push open button. No-op if already open.""" return await self._dispatch_action("open") async def reboot(self): """Reboot device.""" return await self._dispatch_action("reboot") async def ap_mode(self): """Reset device in AP mode (to reconfigure WiFi settings).""" return await self._dispatch_action("apmode") def _get_light_lock(self): """Create the shared lock inside the light command's running loop.""" if self._light_lock is None: self._light_lock = asyncio.Lock() return self._light_lock async def toggle_light(self): """Toggle light when supported by firmware.""" async with self._get_light_lock(): state = await self.get_state() if not state.capabilities.get("light_control"): raise UnsupportedFeatureError("Light control not supported") return await self._dispatch_action("light", wrap_errors=True, retry=0) async def toggle_lock(self): """Toggle lock when supported by firmware.""" state = await self.get_state() if not state.capabilities.get("lock_control"): raise UnsupportedFeatureError("Lock control not supported") return await self._dispatch_action("lock", wrap_errors=True) async def set_light(self, on): """Set light to on/off, only toggling if it isn't already in that state.""" async with self._get_light_lock(): state = await self.get_state() if not state.capabilities.get("light_control"): raise UnsupportedFeatureError("Light control not supported") if state.light_on == on: return None return await self._dispatch_action("light", wrap_errors=True, retry=0) async def set_lock(self, engaged): """Set lock to engaged/disengaged, only toggling if it isn't already in that state.""" state = await self.get_state() if not state.capabilities.get("lock_control"): raise UnsupportedFeatureError("Lock control not supported") if state.lock_engaged == engaged: return None return await self._dispatch_action("lock", wrap_errors=True) async def _dispatch_action(self, action, wrap_errors=False, retry=2): command = CommandDispatcher.build_command(action, self._devkey) result = await self._execute(command, retry=retry, wrap_errors=wrap_errors) if result is None: return None return result.get("result") async def _execute(self, command, retry=2, wrap_errors=False): """Execute command.""" await self._ensure_session() url = f"{self._devip}/{command}" resp = None try: async with async_timeout.timeout(self._timeout): resp = await self.websession.get(url) if resp.status != 200: _LOGGER.error( "Error connecting to Open garage, resp code: %s", resp.status ) if wrap_errors: raise ResponseError(resp.status, url) return None try: result = await resp.json(content_type=None) except ValueError: # Malformed JSON; preserve raw payload for diagnostics text = await resp.text() result = {"_error": "invalid_json", "_raw": text} except aiohttp.ClientError as err: if retry > 0: return await self._execute(command, retry - 1, wrap_errors=wrap_errors) _LOGGER.error("Error connecting to Open garage: %s ", err, exc_info=True) if wrap_errors: raise TransportError(str(err)) from err raise except asyncio.TimeoutError as err: if retry > 0: return await self._execute(command, retry - 1, wrap_errors=wrap_errors) _LOGGER.error("Timed out when connecting to Open garage device") if wrap_errors: raise TransportError(f"Timed out when connecting to {url}") from err raise finally: # Always release the response back to the connector pool, even on # early returns/raises, to avoid leaking connections. if resp is not None: release_result = resp.release() if inspect.isawaitable(release_result): await release_result return result Danielhiversen-pyOpenGarage-30a9ed6/opengarage/cli.py000066400000000000000000000123151525252655600227160ustar00rootroot00000000000000"""Command-line control application for OpenGarage devices.""" import argparse import asyncio import json import os import sys import aiohttp from opengarage import OpenGarage from opengarage.errors import OpenGarageError DEFAULT_DEVKEY = "opendoor" _ACTIONS = { "state": lambda client, args: client.get_state(), "capabilities": lambda client, args: client.get_capabilities(), "open": lambda client, args: client.push_open_button(), "close": lambda client, args: client.push_close_button(), "click": lambda client, args: client.push_button(), "toggle-light": lambda client, args: client.toggle_light(), "toggle-lock": lambda client, args: client.toggle_lock(), "set-light": lambda client, args: client.set_light(args.state == "on"), "set-lock": lambda client, args: client.set_lock(args.state == "on"), "reboot": lambda client, args: client.reboot(), "apmode": lambda client, args: client.ap_mode(), } # Actions that require --state {on,off} to be provided. _STATE_REQUIRED_ACTIONS = {"set-light", "set-lock"} # Actions with side effects serious enough to warrant an explicit confirmation. _CONFIRM_MESSAGES = { "apmode": "This resets the device's WiFi settings and takes it offline from your " "network until reconfigured.", "reboot": "This reboots the device, temporarily taking it offline.", } def _to_plain(value): """Convert client results into a JSON/print-friendly plain value.""" if hasattr(value, "to_dict"): return value.to_dict() return value def _format_dict(data): """Render a flat dict as one 'key: value' line per entry, sorted by key.""" lines = [f" {key}: {value}" for key, value in sorted(data.items())] return "\n".join(lines) def _format_capabilities(capabilities): """Render a capabilities dict as one 'name: yes/no' line per entry.""" lines = [ f" {name}: {'yes' if enabled else 'no'}" for name, enabled in sorted(capabilities.items()) ] return "\n".join(lines) def _print_result(action, value, as_json): plain = _to_plain(value) if as_json: print(json.dumps(plain, indent=2, sort_keys=True)) return if action == "state": print("door_state:", plain["door_state"]) print("capabilities:") print(_format_capabilities(plain["capabilities"])) print("raw:") print(_format_dict(plain["raw"])) elif action == "capabilities": print("capabilities:") print(_format_capabilities(plain)) else: print(f"{action}:", plain) def _confirm(action): """Prompt for confirmation before running a disruptive action.""" message = _CONFIRM_MESSAGES.get(action) if message is None: return True print(f"warning: {message}", file=sys.stderr) try: answer = input(f"Continue with '{action}'? [y/N] ") except (EOFError, KeyboardInterrupt): return False return answer.strip().lower() in ("y", "yes") async def _run(args): client = OpenGarage(args.devip, args.devkey, verify_ssl=args.verify_ssl, timeout=args.timeout) try: result = await _ACTIONS[args.action](client, args) finally: await client.close_connection() _print_result(args.action, result, args.json) def build_parser(): """Build the CLI argument parser.""" parser = argparse.ArgumentParser( prog="opengarage", description="Control an OpenGarage device from the command line." ) parser.add_argument("devip", help="Device IP/host, e.g. 192.168.1.5 or http://192.168.1.5") parser.add_argument( "--devkey", default=os.environ.get("OPENGARAGE_DEVKEY", DEFAULT_DEVKEY), help="Device key. Defaults to $OPENGARAGE_DEVKEY, or 'opendoor' if unset.", ) parser.add_argument("action", choices=sorted(_ACTIONS), help="Action to perform") parser.add_argument( "--state", choices=["on", "off"], help="Desired state for set-light/set-lock (required for those actions)", ) parser.add_argument("--json", action="store_true", help="Print output as JSON") parser.add_argument("--verify-ssl", action="store_true", help="Verify SSL certificates") parser.add_argument("--timeout", type=int, default=10, help="Request timeout in seconds") parser.add_argument( "-y", "--yes", action="store_true", help="Skip confirmation for disruptive actions" ) return parser def main(argv=None): """Entry point for the `opengarage` console script.""" parser = build_parser() args = parser.parse_args(argv) if args.action in _STATE_REQUIRED_ACTIONS and args.state is None: print(f"error: action '{args.action}' requires --state {{on,off}}", file=sys.stderr) return 2 if args.action not in _STATE_REQUIRED_ACTIONS and args.state is not None: print(f"error: --state is not valid for action '{args.action}'", file=sys.stderr) return 2 if not args.yes and not _confirm(args.action): print("aborted", file=sys.stderr) return 1 try: asyncio.run(_run(args)) except (OpenGarageError, aiohttp.ClientError, asyncio.TimeoutError) as err: print(f"error: {err}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main()) # pragma: no cover Danielhiversen-pyOpenGarage-30a9ed6/opengarage/dispatcher.py000066400000000000000000000015361525252655600243000ustar00rootroot00000000000000"""Command construction helpers for the OpenGarage device API.""" from urllib.parse import quote class CommandDispatcher: # pylint: disable=too-few-public-methods """Centralized cc command construction.""" # Per the firmware API, most actions are triggered with value "1", but # light/lock only accept the literal value "toggle". _ACTION_VALUES = { "click": "1", "close": "1", "open": "1", "reboot": "1", "apmode": "1", "light": "toggle", "lock": "toggle", } @classmethod def build_command(cls, action, devkey): """Build the `cc` query string for the given action.""" if action not in cls._ACTION_VALUES: raise ValueError(f"Unsupported action: {action}") return f"cc?dkey={quote(devkey, safe='')}&{action}={cls._ACTION_VALUES[action]}" Danielhiversen-pyOpenGarage-30a9ed6/opengarage/errors.py000066400000000000000000000012551525252655600234640ustar00rootroot00000000000000"""Exception types raised by the OpenGarage client.""" class OpenGarageError(Exception): """Base error for OpenGarage client failures.""" class TransportError(OpenGarageError): """Transport-level failures (timeout/network) after retries.""" class ResponseError(OpenGarageError): """HTTP response errors with status context.""" def __init__(self, status, url): super().__init__(status, url) self.status = status self.url = url def __str__(self): return f"OpenGarage response error: {self.status} for {self.url}" class UnsupportedFeatureError(OpenGarageError): """Raised when a capability-gated action is unavailable.""" Danielhiversen-pyOpenGarage-30a9ed6/opengarage/state.py000066400000000000000000000064021525252655600232670ustar00rootroot00000000000000"""Normalization of raw OpenGarage device state payloads.""" DOOR_STATE_MAP = { 0: "closed", 1: "open", 2: "stopped", 3: "closing", 4: "opening", 5: "unknown", } def _coerce_int(value): if value is None: return None try: return int(value) except (TypeError, ValueError): return None def _coerce_bool(value): if value is None: return None try: return bool(int(value)) except (TypeError, ValueError): return None class NormalizedState: # pylint: disable=too-few-public-methods """Normalized, capability-aware view of a raw OpenGarage state payload.""" # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-instance-attributes def __init__( self, door_state, door_value, light_on=None, lock_engaged=None, obstruction=None, nopenings=None, secv=None, has_swrx=None, pemu=None, raw=None, ): self.door_state = door_state self.door_value = door_value self.light_on = light_on self.lock_engaged = lock_engaged self.obstruction = obstruction self.nopenings = nopenings self.secv = secv self.has_swrx = has_swrx self.pemu = pemu self.raw = raw if raw is not None else {} self.capabilities = { "security_plus": secv is not None or has_swrx is not None, "light_control": light_on is not None, "lock_control": lock_engaged is not None, "obstruction": obstruction is not None, "openings_counter": nopenings is not None, "pemu": pemu is not None, } def to_dict(self): """Return a plain-dict representation of this state.""" return { "door_state": self.door_state, "door_value": self.door_value, "light_on": self.light_on, "lock_engaged": self.lock_engaged, "obstruction": self.obstruction, "nopenings": self.nopenings, "secv": self.secv, "has_swrx": self.has_swrx, "pemu": self.pemu, "capabilities": dict(self.capabilities), "raw": dict(self.raw) if isinstance(self.raw, dict) else self.raw, } def normalize_state(payload): """Normalize a raw OpenGarage device payload into a `NormalizedState`.""" if not isinstance(payload, dict): return NormalizedState("unknown", None, raw={"_raw": payload}) door_value = _coerce_int(payload.get("door")) door_state = DOOR_STATE_MAP.get(door_value, "unknown") secv = _coerce_int(payload.get("secv")) has_swrx = _coerce_bool(payload.get("has_swrx")) light_on = _coerce_bool(payload.get("light")) lock_engaged = _coerce_bool(payload.get("lock")) obstruction = _coerce_bool(payload.get("obstruct")) nopenings = _coerce_int(payload.get("nopenings")) pemu = _coerce_int(payload.get("pemu")) return NormalizedState( door_state=door_state, door_value=door_value, light_on=light_on, lock_engaged=lock_engaged, obstruction=obstruction, nopenings=nopenings, secv=secv, has_swrx=has_swrx, pemu=pemu, raw=payload, ) Danielhiversen-pyOpenGarage-30a9ed6/pytest.ini000066400000000000000000000002141525252655600215110ustar00rootroot00000000000000[pytest] pythonpath = . asyncio_mode = auto testpaths = tests python_files = test_*.py addopts = --cov=opengarage --cov-report=term-missing Danielhiversen-pyOpenGarage-30a9ed6/setup.py000066400000000000000000000024261525252655600212010ustar00rootroot00000000000000from setuptools import setup setup( name="open_garage", packages=["opengarage"], install_requires=["aiohttp>=3.14.3", "async_timeout>=4.0.3"], entry_points={ "console_scripts": ["opengarage=opengarage.cli:main"], }, extras_require={ "dev": [ "dlint>=0.16.0", "flake8>=7.3.0", "flake8-bandit>=4.1.1", "flake8-bugbear>=25.11.29", "flake8-deprecated>=2.3.0", "flake8-executable>=2.1.3", "isort>=9.0.1", "pylint>=4.0.8", "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0" ] }, version="0.3.1", description="A python3 library to communicate with Open Garage", python_requires=">=3.10", author="Daniel Hjelseth Høyer", author_email="mail@dahoiv.net", url="https://github.com/Danielhiversen/pyOpenGarage", license="MIT", classifiers=[ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Topic :: Home Automation", "Topic :: Software Development :: Libraries :: Python Modules", ], ) Danielhiversen-pyOpenGarage-30a9ed6/tests/000077500000000000000000000000001525252655600206255ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/tests/test_cli.py000066400000000000000000000161521525252655600230120ustar00rootroot00000000000000import json import pytest from opengarage import cli from opengarage.errors import ResponseError from opengarage.state import NormalizedState class FakeClient: def __init__(self, *args, **kwargs): self.close_connection_called = False async def get_state(self): return NormalizedState("open", 1, light_on=True, raw={"door": 1, "light": 1}) async def get_capabilities(self): return {"light_control": True} async def push_open_button(self): return "success" async def set_light(self, on): self.set_light_called_with = on return "success" async def set_lock(self, engaged): self.set_lock_called_with = engaged return "success" async def reboot(self): return "success" async def ap_mode(self): return "success" async def close_connection(self): self.close_connection_called = True def test_build_parser_rejects_unknown_action(): parser = cli.build_parser() with pytest.raises(SystemExit): parser.parse_args(["http://device", "not-a-real-action"]) def test_main_state_text_output(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) exit_code = cli.main(["http://device", "state"]) out = capsys.readouterr().out assert exit_code == 0 assert "door_state: open" in out assert "capabilities:" in out assert "raw:" in out assert " door: 1" in out def test_main_state_json_output(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) exit_code = cli.main(["http://device", "state", "--json"]) out = capsys.readouterr().out assert exit_code == 0 payload = json.loads(out) assert payload["door_state"] == "open" def test_main_action_open(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) exit_code = cli.main(["http://device", "open"]) out = capsys.readouterr().out assert exit_code == 0 assert "open: success" in out def test_main_set_light_requires_state(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) exit_code = cli.main(["http://device", "set-light"]) err = capsys.readouterr().err assert exit_code == 2 assert "--state" in err def test_main_rejects_state_for_unrelated_action(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) exit_code = cli.main(["http://device", "toggle-light", "--state", "on"]) err = capsys.readouterr().err assert exit_code == 2 assert "--state" in err def test_main_apmode_aborts_on_eof(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) def _raise_eof(prompt): raise EOFError monkeypatch.setattr("builtins.input", _raise_eof) exit_code = cli.main(["http://device", "apmode"]) err = capsys.readouterr().err assert exit_code == 1 assert "aborted" in err def test_main_set_light_on(monkeypatch, capsys): client_holder = {} class TrackingClient(FakeClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) client_holder["client"] = self monkeypatch.setattr(cli, "OpenGarage", TrackingClient) exit_code = cli.main(["http://device", "set-light", "--state", "on"]) out = capsys.readouterr().out assert exit_code == 0 assert "set-light: success" in out assert client_holder["client"].set_light_called_with is True def test_main_set_lock_off(monkeypatch, capsys): client_holder = {} class TrackingClient(FakeClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) client_holder["client"] = self monkeypatch.setattr(cli, "OpenGarage", TrackingClient) exit_code = cli.main(["http://device", "set-lock", "--state", "off"]) out = capsys.readouterr().out assert exit_code == 0 assert "set-lock: success" in out assert client_holder["client"].set_lock_called_with is False def test_main_capabilities_text_output(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) exit_code = cli.main(["http://device", "capabilities"]) out = capsys.readouterr().out assert exit_code == 0 assert "capabilities:" in out assert "light_control: yes" in out def test_main_state_json_output_is_indented(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) exit_code = cli.main(["http://device", "state", "--json"]) out = capsys.readouterr().out assert exit_code == 0 assert "\n" in out.strip() def test_main_closes_connection_on_error(monkeypatch): closed = {} class FailingClient(FakeClient): async def get_state(self): raise ResponseError(500, "http://device/jc") async def close_connection(self): closed["called"] = True monkeypatch.setattr(cli, "OpenGarage", FailingClient) exit_code = cli.main(["http://device", "state"]) assert exit_code == 1 assert closed["called"] is True def test_main_prints_error_to_stderr(monkeypatch, capsys): class FailingClient(FakeClient): async def get_state(self): raise ResponseError(500, "http://device/jc") monkeypatch.setattr(cli, "OpenGarage", FailingClient) exit_code = cli.main(["http://device", "state"]) err = capsys.readouterr().err assert exit_code == 1 assert "error:" in err def test_main_apmode_requires_confirmation(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) monkeypatch.setattr("builtins.input", lambda prompt: "n") exit_code = cli.main(["http://device", "apmode"]) err = capsys.readouterr().err assert exit_code == 1 assert "aborted" in err def test_main_apmode_confirmed_via_prompt(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) monkeypatch.setattr("builtins.input", lambda prompt: "y") exit_code = cli.main(["http://device", "apmode"]) out = capsys.readouterr().out assert exit_code == 0 assert "apmode: success" in out def test_main_apmode_skips_confirmation_with_yes_flag(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) def _unexpected_input(prompt): raise AssertionError("input() should not be called when --yes is passed") monkeypatch.setattr("builtins.input", _unexpected_input) exit_code = cli.main(["http://device", "apmode", "--yes"]) out = capsys.readouterr().out assert exit_code == 0 assert "apmode: success" in out def test_main_reboot_requires_confirmation(monkeypatch, capsys): monkeypatch.setattr(cli, "OpenGarage", FakeClient) monkeypatch.setattr("builtins.input", lambda prompt: "n") exit_code = cli.main(["http://device", "reboot"]) err = capsys.readouterr().err assert exit_code == 1 assert "aborted" in err def test_main_state_does_not_prompt(monkeypatch): monkeypatch.setattr(cli, "OpenGarage", FakeClient) def _unexpected_input(prompt): raise AssertionError("input() should not be called for non-disruptive actions") monkeypatch.setattr("builtins.input", _unexpected_input) exit_code = cli.main(["http://device", "state"]) assert exit_code == 0 Danielhiversen-pyOpenGarage-30a9ed6/tests/test_client.py000066400000000000000000000161341525252655600235210ustar00rootroot00000000000000import asyncio import unittest import aiohttp from opengarage import OpenGarage from opengarage.errors import (ResponseError, TransportError, UnsupportedFeatureError) class FakeResponse: def __init__(self, status, payload=None, text=None): self.status = status self._payload = payload self._text = text or "" self.released = False async def json(self, content_type=None): if isinstance(self._payload, Exception): raise self._payload return self._payload async def text(self): return self._text async def release(self): self.released = True class FakeSession: def __init__(self, responses): self._responses = list(responses) self.requested_urls = [] async def get(self, url): self.requested_urls.append(url) response = self._responses.pop(0) if isinstance(response, Exception): raise response return response async def close(self): return None class TestOpenGarageClient(unittest.TestCase): def test_get_state_normalizes(self): session = FakeSession([FakeResponse(200, {"door": 1, "light": 1})]) client = OpenGarage("http://device", "key", websession=session) state = asyncio.run(client.get_state()) self.assertEqual(state.door_state, "open") self.assertTrue(state.capabilities["light_control"]) def test_get_state_non_200_raises(self): session = FakeSession([FakeResponse(500, {"error": "bad"})]) client = OpenGarage("http://device", "key", websession=session) with self.assertRaises(ResponseError): asyncio.run(client.get_state()) def test_update_state_preserves_backward_behavior(self): session = FakeSession([FakeResponse(500, {"error": "bad"})]) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client.update_state()) self.assertIsNone(result) def test_toggle_light_unsupported(self): session = FakeSession([FakeResponse(200, {"door": 1})]) client = OpenGarage("http://device", "key", websession=session) with self.assertRaises(UnsupportedFeatureError): asyncio.run(client.toggle_light()) def test_toggle_light_supported(self): session = FakeSession( [ FakeResponse(200, {"door": 1, "light": 1}), FakeResponse(200, {"result": "success"}), ] ) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client.toggle_light()) self.assertEqual(result, "success") self.assertTrue(session.requested_urls[-1].endswith("light=toggle")) def test_get_capabilities(self): session = FakeSession([FakeResponse(200, {"door": 1, "lock": 1})]) client = OpenGarage("http://device", "key", websession=session) capabilities = asyncio.run(client.get_capabilities()) self.assertIsInstance(capabilities, dict) self.assertTrue(capabilities["lock_control"]) def test_toggle_lock_unsupported(self): session = FakeSession([FakeResponse(200, {"door": 1})]) client = OpenGarage("http://device", "key", websession=session) with self.assertRaises(UnsupportedFeatureError): asyncio.run(client.toggle_lock()) def test_toggle_lock_supported(self): session = FakeSession( [ FakeResponse(200, {"door": 1, "lock": 1}), FakeResponse(200, {"result": "success"}), ] ) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client.toggle_lock()) self.assertEqual(result, "success") self.assertTrue(session.requested_urls[-1].endswith("lock=toggle")) def test_set_light_unsupported(self): session = FakeSession([FakeResponse(200, {"door": 1})]) client = OpenGarage("http://device", "key", websession=session) with self.assertRaises(UnsupportedFeatureError): asyncio.run(client.set_light(True)) def test_set_light_noop_when_already_desired_state(self): session = FakeSession([FakeResponse(200, {"door": 1, "light": 1})]) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client.set_light(True)) self.assertIsNone(result) self.assertEqual(len(session.requested_urls), 1) def test_set_light_toggles_when_state_differs(self): session = FakeSession( [ FakeResponse(200, {"door": 1, "light": 0}), FakeResponse(200, {"result": "success"}), ] ) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client.set_light(True)) self.assertEqual(result, "success") self.assertTrue(session.requested_urls[-1].endswith("light=toggle")) def test_set_lock_unsupported(self): session = FakeSession([FakeResponse(200, {"door": 1})]) client = OpenGarage("http://device", "key", websession=session) with self.assertRaises(UnsupportedFeatureError): asyncio.run(client.set_lock(True)) def test_set_lock_noop_when_already_desired_state(self): session = FakeSession([FakeResponse(200, {"door": 1, "lock": 0})]) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client.set_lock(False)) self.assertIsNone(result) self.assertEqual(len(session.requested_urls), 1) def test_set_lock_toggles_when_state_differs(self): session = FakeSession( [ FakeResponse(200, {"door": 1, "lock": 0}), FakeResponse(200, {"result": "success"}), ] ) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client.set_lock(True)) self.assertEqual(result, "success") self.assertTrue(session.requested_urls[-1].endswith("lock=toggle")) def test_execute_handles_invalid_json_payload(self): session = FakeSession( [FakeResponse(200, ValueError("bad-json"), text="not-json")] ) client = OpenGarage("http://device", "key", websession=session) result = asyncio.run(client._execute("jc")) self.assertEqual(result["_error"], "invalid_json") self.assertEqual(result["_raw"], "not-json") def test_execute_wrap_errors_raises_transport_error_on_client_error(self): session = FakeSession([aiohttp.ClientError("boom")]) client = OpenGarage("http://device", "key", websession=session) with self.assertRaises(TransportError): asyncio.run(client._execute("jc", retry=0, wrap_errors=True)) def test_execute_wrap_errors_raises_transport_error_on_timeout(self): session = FakeSession([asyncio.TimeoutError()]) client = OpenGarage("http://device", "key", websession=session) with self.assertRaises(TransportError): asyncio.run(client._execute("jc", retry=0, wrap_errors=True)) if __name__ == "__main__": unittest.main() Danielhiversen-pyOpenGarage-30a9ed6/tests/test_dispatcher.py000066400000000000000000000020311525252655600243600ustar00rootroot00000000000000import unittest from opengarage.dispatcher import CommandDispatcher class TestCommandDispatcher(unittest.TestCase): def test_build_command(self): command = CommandDispatcher.build_command("open", "abc123") self.assertEqual(command, "cc?dkey=abc123&open=1") def test_build_command_light_uses_toggle(self): command = CommandDispatcher.build_command("light", "abc123") self.assertEqual(command, "cc?dkey=abc123&light=toggle") def test_build_command_lock_uses_toggle(self): command = CommandDispatcher.build_command("lock", "abc123") self.assertEqual(command, "cc?dkey=abc123&lock=toggle") def test_build_command_url_encodes_devkey_special_chars(self): command = CommandDispatcher.build_command("open", "a&b=c") self.assertEqual(command, "cc?dkey=a%26b%3Dc&open=1") def test_invalid_action_raises(self): with self.assertRaises(ValueError): CommandDispatcher.build_command("invalid", "abc123") if __name__ == "__main__": unittest.main() Danielhiversen-pyOpenGarage-30a9ed6/tests/test_errors.py000066400000000000000000000016061525252655600235550ustar00rootroot00000000000000import unittest from opengarage.errors import (OpenGarageError, ResponseError, TransportError, UnsupportedFeatureError) class TestErrors(unittest.TestCase): def test_error_hierarchy(self): self.assertTrue(issubclass(TransportError, OpenGarageError)) self.assertTrue(issubclass(ResponseError, OpenGarageError)) self.assertTrue(issubclass(UnsupportedFeatureError, OpenGarageError)) def test_response_error_fields(self): err = ResponseError(status=500, url="http://example/jc") self.assertEqual(err.status, 500) self.assertEqual(err.url, "http://example/jc") self.assertIn("500", str(err)) def test_unsupported_feature_message(self): err = UnsupportedFeatureError("Light control not supported") self.assertIn("Light control", str(err)) if __name__ == "__main__": unittest.main() Danielhiversen-pyOpenGarage-30a9ed6/tests/test_light.py000066400000000000000000000141051525252655600233460ustar00rootroot00000000000000"""Tests for OpenGarage opener light control.""" import asyncio from functools import partial from types import SimpleNamespace from unittest.mock import AsyncMock import aiohttp import pytest from opengarage import OpenGarage from opengarage.errors import TransportError, UnsupportedFeatureError from opengarage.state import normalize_state @pytest.fixture def client(): """Create a client without connecting to a device.""" return OpenGarage("http://device", "abc123&=?/", websession=object()) @pytest.mark.parametrize( "response,expected", [({"result": 1}, 1), ({"result": 2}, 2), ({}, None), (None, None)], ) async def test_toggle_light(client, response, expected): """Pass the key as a query parameter and never retry a toggle.""" client.get_state = AsyncMock(return_value=normalize_state({"light": 0})) client._execute = AsyncMock(return_value=response) assert await client.toggle_light() == expected client._execute.assert_awaited_once_with( "cc?dkey=abc123%26%3D%3F%2F&light=toggle", retry=0, wrap_errors=True ) @pytest.mark.parametrize("current,requested", [(0, False), (1, True)]) async def test_set_light_is_idempotent(client, current, requested): """Do not toggle a light that already has the requested state.""" client._execute = AsyncMock(return_value={"light": current}) assert await client.set_light(requested) is None client._execute.assert_awaited_once_with("jc", wrap_errors=True) @pytest.mark.parametrize("current,requested", [(0, True), (1, False)]) @pytest.mark.parametrize( "response,expected", [({"result": 1}, 1), ({"result": 2}, 2), ({"result": 3}, 3), (None, None)], ) async def test_set_light_when_state_differs(client, current, requested, response, expected): """Read the state before toggling and preserve command failures.""" client._execute = AsyncMock(side_effect=[{"light": current}, response]) assert await client.set_light(requested) == expected assert client._execute.await_count == 2 client._execute.assert_awaited_with( "cc?dkey=abc123%26%3D%3F%2F&light=toggle", retry=0, wrap_errors=True ) @pytest.mark.parametrize( "state", [None, {"door": 0}, {"light": None}, {"light": "unknown"}] ) async def test_set_light_without_valid_state(client, state): """An absent or unknown light state must never trigger a toggle.""" client._execute = AsyncMock(return_value=state) with pytest.raises(UnsupportedFeatureError): await client.set_light(True) client._execute.assert_awaited_once_with("jc", wrap_errors=True) @pytest.mark.parametrize("initial,requested", [(0, True), (1, False)]) async def test_concurrent_set_light(client, initial, requested): """Two same-state commands must not toggle twice from one snapshot.""" state = initial async def read_state(): snapshot = normalize_state({"light": state}) await asyncio.sleep(0) return snapshot async def execute(command, retry, wrap_errors): nonlocal state state = 1 - state await asyncio.sleep(0) return {"result": 1} client.get_state = AsyncMock(side_effect=read_state) client._execute = AsyncMock(side_effect=execute) results = await asyncio.gather( client.set_light(requested), client.set_light(requested) ) assert results == [1, None] assert state == int(requested) assert client.get_state.await_count == 2 client._execute.assert_awaited_once() @pytest.mark.parametrize("error", [aiohttp.ClientError, asyncio.TimeoutError]) async def test_toggle_does_not_retry_network_failure(client, error): """An uncertain command outcome must not cause a second toggle.""" client.get_state = AsyncMock(return_value=normalize_state({"light": 0})) client.websession = SimpleNamespace(get=AsyncMock(side_effect=error)) with pytest.raises(TransportError): await client.toggle_light() client.websession.get.assert_awaited_once_with( "http://device/cc?dkey=abc123%26%3D%3F%2F&light=toggle", ) async def test_set_light_lock_released_after_error(client): """A failed state read must not block subsequent commands.""" client._execute = AsyncMock(side_effect=[aiohttp.ClientError(), {"light": 1}]) with pytest.raises(aiohttp.ClientError): await client.set_light(True) assert await asyncio.wait_for(client.set_light(True), timeout=1) is None @pytest.mark.parametrize( "commands,expected_state,expected_toggles", [ (("on", "on"), 1, 1), (("off", "off"), 0, 0), (("on", "off"), 0, 2), (("off", "on"), 1, 1), (("toggle", "on"), 1, 1), (("on", "toggle"), 0, 2), (("toggle", "toggle"), 0, 2), ], ) def test_light_commands_after_loop_start(commands, expected_state, expected_toggles): """A client constructed before the running loop must serialize light calls.""" construction_loop = asyncio.new_event_loop() asyncio.set_event_loop(construction_loop) try: connection = OpenGarage("http://device", "key", websession=object()) finally: asyncio.set_event_loop(None) construction_loop.close() state = 0 async def read_state(): snapshot = normalize_state({"light": state}) await asyncio.sleep(0) return snapshot async def execute(command, retry, wrap_errors): nonlocal state state = 1 - state await asyncio.sleep(0) return {"result": 1} connection.get_state = AsyncMock(side_effect=read_state) connection._execute = AsyncMock(side_effect=execute) actions = { "on": partial(connection.set_light, True), "off": partial(connection.set_light, False), "toggle": connection.toggle_light, } async def run_commands(): return await asyncio.wait_for( asyncio.gather(*(actions[command]() for command in commands)), timeout=1 ) results = asyncio.run(run_commands()) assert results.count(1) == expected_toggles assert results.count(None) == 2 - expected_toggles assert state == expected_state assert connection._execute.await_count == expected_toggles Danielhiversen-pyOpenGarage-30a9ed6/tests/test_opengarage_client.py000066400000000000000000000165101525252655600257070ustar00rootroot00000000000000import asyncio import logging from types import SimpleNamespace from unittest.mock import AsyncMock import aiohttp import pytest from opengarage import OpenGarage class ResponseStub: def __init__(self, status, payload): self.status = status self._payload = payload self.json_calls = [] self.released = False async def json(self, content_type=None): self.json_calls.append(content_type) return self._payload async def release(self): self.released = True class SyncReleaseResponse(ResponseStub): def release(self): self.released = True def make_client(session): return OpenGarage("http://device", "devkey", verify_ssl=False, websession=session) def test_init_uses_provided_session(): session = object() client = OpenGarage("http://device", "devkey", websession=session) assert client.websession is session def test_init_does_not_create_session_eagerly(): client = OpenGarage("http://device", "devkey") assert client.websession is None @pytest.mark.asyncio async def test_ensure_session_creates_session_lazily(monkeypatch): session = object() monkeypatch.setattr(aiohttp, "ClientSession", lambda connector=None: session) client = OpenGarage("http://device", "devkey") await client._ensure_session() assert client.websession is session def test_device_url_returns_devip(): client = OpenGarage("http://device", "devkey", websession=SimpleNamespace()) assert client.device_url == "http://device" def test_device_url_adds_scheme_for_bare_host(): client = OpenGarage("192.168.1.5:80", "devkey", websession=SimpleNamespace()) assert client.device_url == "http://192.168.1.5:80" def test_device_url_adds_scheme_for_bare_host_without_port(): client = OpenGarage("192.168.1.5", "devkey", websession=SimpleNamespace()) assert client.device_url == "http://192.168.1.5" def test_device_url_preserves_https_scheme(): client = OpenGarage("https://device", "devkey", websession=SimpleNamespace()) assert client.device_url == "https://device" def test_device_url_strips_trailing_slash(): client = OpenGarage("http://device/", "devkey", websession=SimpleNamespace()) assert client.device_url == "http://device" @pytest.mark.asyncio async def test_close_connection_closes_session(): session = SimpleNamespace(close=AsyncMock()) client = make_client(session) await client.close_connection() session.close.assert_awaited_once() @pytest.mark.asyncio async def test_close_connection_noop_when_no_session_created(): client = OpenGarage("http://device", "devkey") await client.close_connection() # should not raise AttributeError @pytest.mark.asyncio async def test_update_state_calls_execute(): session = SimpleNamespace() client = make_client(session) client._execute = AsyncMock(return_value={"door": "open"}) result = await client.update_state() assert result == {"door": "open"} client._execute.assert_awaited_once_with("jc") @pytest.mark.asyncio @pytest.mark.parametrize( "method_name,command", [ ("push_button", "cc?dkey=devkey&click=1"), ("push_close_button", "cc?dkey=devkey&close=1"), ("push_open_button", "cc?dkey=devkey&open=1"), ("reboot", "cc?dkey=devkey&reboot=1"), ("ap_mode", "cc?dkey=devkey&apmode=1"), ], ) async def test_command_methods_return_result(method_name, command): session = SimpleNamespace() client = make_client(session) client._execute = AsyncMock(return_value={"result": "ok"}) method = getattr(client, method_name) result = await method() assert result == "ok" client._execute.assert_awaited_once_with(command, retry=2, wrap_errors=False) @pytest.mark.asyncio @pytest.mark.parametrize( "method_name,command", [ ("push_button", "cc?dkey=devkey&click=1"), ("push_close_button", "cc?dkey=devkey&close=1"), ("push_open_button", "cc?dkey=devkey&open=1"), ("reboot", "cc?dkey=devkey&reboot=1"), ("ap_mode", "cc?dkey=devkey&apmode=1"), ], ) async def test_command_methods_return_none_on_no_result(method_name, command): session = SimpleNamespace() client = make_client(session) client._execute = AsyncMock(return_value=None) method = getattr(client, method_name) result = await method() assert result is None client._execute.assert_awaited_once_with(command, retry=2, wrap_errors=False) @pytest.mark.asyncio async def test_execute_success_returns_json(): response = ResponseStub(200, {"ok": True}) session = SimpleNamespace(get=AsyncMock(return_value=response)) client = make_client(session) result = await client._execute("jc") assert result == {"ok": True} session.get.assert_awaited_once_with("http://device/jc") assert response.json_calls == [None] assert response.released is True @pytest.mark.asyncio async def test_execute_releases_sync_response(): response = SyncReleaseResponse(200, {"ok": True}) session = SimpleNamespace(get=AsyncMock(return_value=response)) client = make_client(session) result = await client._execute("jc") assert result == {"ok": True} assert response.released is True @pytest.mark.asyncio async def test_execute_non_200_returns_none_and_logs(caplog): response = ResponseStub(500, {"ok": False}) session = SimpleNamespace(get=AsyncMock(return_value=response)) client = make_client(session) caplog.set_level(logging.ERROR) result = await client._execute("jc") assert result is None assert response.released is True assert any("resp code: 500" in record.message for record in caplog.records) @pytest.mark.asyncio async def test_execute_retries_on_client_error_then_succeeds(): response = ResponseStub(200, {"ok": True}) session = SimpleNamespace( get=AsyncMock(side_effect=[aiohttp.ClientError("boom"), response]) ) client = make_client(session) result = await client._execute("jc") assert result == {"ok": True} assert session.get.await_count == 2 @pytest.mark.asyncio async def test_execute_raises_after_client_error_retries_exhausted(): session = SimpleNamespace( get=AsyncMock( side_effect=[ aiohttp.ClientError("boom"), aiohttp.ClientError("boom"), aiohttp.ClientError("boom"), ] ) ) client = make_client(session) with pytest.raises(aiohttp.ClientError): await client._execute("jc") assert session.get.await_count == 3 @pytest.mark.asyncio async def test_execute_retries_on_timeout_then_succeeds(): response = ResponseStub(200, {"ok": True}) session = SimpleNamespace( get=AsyncMock(side_effect=[asyncio.TimeoutError(), response]) ) client = make_client(session) result = await client._execute("jc") assert result == {"ok": True} assert session.get.await_count == 2 @pytest.mark.asyncio async def test_execute_raises_after_timeout_retries_exhausted(): session = SimpleNamespace( get=AsyncMock( side_effect=[ asyncio.TimeoutError(), asyncio.TimeoutError(), asyncio.TimeoutError(), ] ) ) client = make_client(session) with pytest.raises(asyncio.TimeoutError): await client._execute("jc") assert session.get.await_count == 3 Danielhiversen-pyOpenGarage-30a9ed6/tests/test_state.py000066400000000000000000000057761525252655600233750ustar00rootroot00000000000000import unittest from opengarage.state import NormalizedState, normalize_state class TestStateNormalizer(unittest.TestCase): def test_known_door_states(self): payload = {"door": 0} state = normalize_state(payload) self.assertEqual(state.door_state, "closed") payload = {"door": 1} state = normalize_state(payload) self.assertEqual(state.door_state, "open") payload = {"door": 2} state = normalize_state(payload) self.assertEqual(state.door_state, "stopped") payload = {"door": 3} state = normalize_state(payload) self.assertEqual(state.door_state, "closing") payload = {"door": 4} state = normalize_state(payload) self.assertEqual(state.door_state, "opening") payload = {"door": 5} state = normalize_state(payload) self.assertEqual(state.door_state, "unknown") def test_unknown_door_state(self): payload = {"door": 99} state = normalize_state(payload) self.assertEqual(state.door_state, "unknown") def test_optional_fields_and_capabilities(self): payload = { "door": 1, "secv": 2, "has_swrx": 1, "light": 1, "lock": 0, "obstruct": 1, "nopenings": 42, "pemu": 7, } state = normalize_state(payload) self.assertTrue(state.capabilities["security_plus"]) self.assertTrue(state.capabilities["light_control"]) self.assertTrue(state.capabilities["lock_control"]) self.assertTrue(state.capabilities["obstruction"]) self.assertTrue(state.capabilities["openings_counter"]) self.assertTrue(state.capabilities["pemu"]) self.assertEqual(state.nopenings, 42) self.assertEqual(state.pemu, 7) def test_non_dict_payload_is_safe(self): state = normalize_state("not-a-dict") self.assertIsInstance(state, NormalizedState) self.assertEqual(state.door_state, "unknown") self.assertIn("_raw", state.raw) def test_invalid_optional_values_and_to_dict(self): payload = { "door": "not-int", "secv": "invalid", "has_swrx": "invalid", "light": "invalid", "lock": "invalid", "obstruct": "invalid", "nopenings": "invalid", "pemu": "invalid", } state = normalize_state(payload) self.assertIsNone(state.door_value) self.assertIsNone(state.secv) self.assertIsNone(state.has_swrx) self.assertIsNone(state.light_on) self.assertIsNone(state.lock_engaged) self.assertIsNone(state.obstruction) self.assertIsNone(state.nopenings) self.assertIsNone(state.pemu) state_dict = state.to_dict() self.assertEqual(state_dict["door_state"], "unknown") self.assertIn("capabilities", state_dict) self.assertEqual(state_dict["raw"], payload) if __name__ == "__main__": unittest.main() Danielhiversen-pyOpenGarage-30a9ed6/thoughts/000077500000000000000000000000001525252655600213305ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/thoughts/shared/000077500000000000000000000000001525252655600225765ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/thoughts/shared/designs/000077500000000000000000000000001525252655600242325ustar00rootroot000000000000002026-05-22-opengarage-firmware-1-2-4-design.md000066400000000000000000000120631525252655600333600ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/thoughts/shared/designs--- date: 2026-05-22 topic: "OpenGarage firmware feature expansion (v1.2.x)" status: draft --- ## Problem Statement We need to expand this client so it supports the latest OpenGarage firmware capabilities exposed in v1.2.x, especially new door states and Security+ controls/telemetry. Today the library only wraps a narrow subset of commands (`jc` read, a few `cc` actions) and exposes raw responses with minimal structure, which makes it hard to safely adopt newer fields and behaviors. ## Constraints - Keep the public API backward compatible for existing consumers. - Preserve async behavior and simple client ergonomics. - Maintain compatibility with older firmware that does not expose new fields. - Avoid a large architecture rewrite in this repository’s current lightweight structure. - Do not require MQTT usage for core HTTP functionality. ## Approach We will evolve from a fixed-command wrapper to a **capability-aware API surface** while keeping the current class model. The chosen approach is a **thin compatibility layer**: we add richer response parsing, optional feature methods, and runtime capability detection from `/jc` fields. Why this approach: - Lowest disruption to current users. - Fits the current single-class client style. - Lets us add modern firmware support without forcing version pinning. Alternatives considered: - Full multi-module SDK redesign: cleaner long-term, but overkill for this package size. - Strict firmware-version branching: simpler logic but brittle when fields roll out incrementally. ## Architecture High-level structure remains a single OpenGarage client, with three explicit layers of responsibility. 1. **Transport Layer** - Handles request execution, retries, timeout, and response parsing. - Normalizes API errors into stable client-level failure modes. 2. **State/Capability Layer** - Converts `/jc` payload into a normalized state model. - Detects optional capabilities (Security+, light/lock control, obstruction telemetry) based on field presence. 3. **Action Layer** - Exposes current actions plus new optional actions (light toggle, lock toggle). - Gates unsupported actions cleanly when device capability is absent. ## Components ### OpenGarage Client (core façade) - Continues to be the main user entrypoint. - Adds explicit state retrieval returning normalized status + raw payload. - Adds capability query helpers for feature detection. ### Command Dispatcher - Centralizes command/query construction for `cc` actions. - Supports extending action set without duplicating request logic. ### State Normalizer - Maps `door` values to stable semantic states including intermediate states (`opening`, `closing`, `stopped`). - Parses optional v1.2.x fields (`secv`, `has_swrx`, `light`, `lock`, `obstruct`, `nopenings`, `pemu`) when available. ### Compatibility Guard - Ensures absent fields on older firmware degrade gracefully. - Avoids raising errors for unknown extra fields from future firmware. ## Data Flow 1. Consumer calls state refresh. 2. Client requests `/jc`. 3. Transport validates HTTP response and parses JSON. 4. State normalizer maps raw values into normalized state + capability flags. 5. Caller receives normalized state (plus optional raw payload for advanced use). For actions: 1. Consumer calls action method (open/close/click/light/lock/etc.). 2. Action layer validates capability preconditions when required. 3. Dispatcher sends `/cc` with the appropriate control argument. 4. Response result is normalized into a consistent success/failure contract. ## Error Handling - **Transport failures** (timeout/network): retain retry behavior, then raise a clear client exception. - **HTTP non-200**: treat as operation failure with structured error context. - **Invalid JSON / malformed fields**: fail state normalization safely, preserving raw payload for diagnostics. - **Unsupported feature call** (e.g., lock toggle on non-Security+): return deterministic unsupported-feature error. - **Backward compatibility path**: missing optional fields are interpreted as “not supported,” not hard failures. ## Testing Strategy We add focused tests around compatibility boundaries rather than exhaustive firmware emulation. 1. **State mapping tests** - Validate all known `door` states and unknown fallback handling. 2. **Optional-field parsing tests** - Verify behavior when Security+ fields are present vs absent. 3. **Action capability tests** - Ensure light/lock actions succeed when capabilities exist and fail predictably when they do not. 4. **Transport/error tests** - Timeout retry behavior. - Non-200 and malformed payload handling. 5. **Backward compatibility tests** - Existing open/close/click flows remain unchanged for older payload shapes. ## Open Questions - Whether to expose config-level endpoints (`/jo`) in this phase or keep scope limited to operational control/state. - Whether OTC cloud-token behavior should be handled in this library now or in a separate cloud-focused client. - Whether MQTT-facing features should be represented as first-class APIs or documented as out of scope for this package. Danielhiversen-pyOpenGarage-30a9ed6/thoughts/shared/plans/000077500000000000000000000000001525252655600237135ustar00rootroot00000000000000Danielhiversen-pyOpenGarage-30a9ed6/thoughts/shared/plans/2026-05-22-opengarage-firmware-1-2-4.md000066400000000000000000000432631525252655600316570ustar00rootroot00000000000000# OpenGarage Firmware v1.2.x Expansion Implementation Plan **Goal:** Add capability-aware state parsing and optional v1.2.x actions while preserving the existing OpenGarage client API. **Architecture:** Keep the single `OpenGarage` façade but introduce lightweight helper modules (dispatcher, state normalization, error types). Existing methods continue to return raw responses and behavior, while new capability-aware methods use normalized state + structured errors for unsupported features and transport failures. This aligns with the design’s thin compatibility layer and avoids a full rewrite. **Design:** `thoughts/shared/designs/2026-05-22-opengarage-firmware-1-2-4-design.md` --- ## Dependency Graph ``` Batch 1 (parallel): 1.1, 1.2, 1.3 [foundation - no deps] Batch 2 (parallel): 2.1 [core - depends on batch 1] ``` --- ## Batch 1: Foundation (parallel - 3 implementers) All tasks in this batch have NO dependencies and run simultaneously. ### Task 1.1: Client Error Types **File:** `opengarage/errors.py` **Test:** `tests/test_errors.py` **Depends:** none ```python import unittest from opengarage.errors import ( OpenGarageError, ResponseError, TransportError, UnsupportedFeatureError, ) class TestErrors(unittest.TestCase): def test_error_hierarchy(self): self.assertTrue(issubclass(TransportError, OpenGarageError)) self.assertTrue(issubclass(ResponseError, OpenGarageError)) self.assertTrue(issubclass(UnsupportedFeatureError, OpenGarageError)) def test_response_error_fields(self): err = ResponseError(status=500, url="http://example/jc") self.assertEqual(err.status, 500) self.assertEqual(err.url, "http://example/jc") self.assertIn("500", str(err)) def test_unsupported_feature_message(self): err = UnsupportedFeatureError("Light control not supported") self.assertIn("Light control", str(err)) if __name__ == "__main__": unittest.main() ``` ```python class OpenGarageError(Exception): """Base error for OpenGarage client failures.""" class TransportError(OpenGarageError): """Transport-level failures (timeout/network) after retries.""" class ResponseError(OpenGarageError): """HTTP response errors with status context.""" def __init__(self, status, url): super().__init__("OpenGarage response error: %s for %s" % (status, url)) self.status = status self.url = url class UnsupportedFeatureError(OpenGarageError): """Raised when a capability-gated action is unavailable.""" ``` **Verify:** `python -m unittest tests/test_errors.py` **Commit:** `feat(core): add OpenGarage error types` --- ### Task 1.2: State Normalizer + Capability Detection **File:** `opengarage/state.py` **Test:** `tests/test_state.py` **Depends:** none ```python import unittest from opengarage.state import NormalizedState, normalize_state class TestStateNormalizer(unittest.TestCase): def test_known_door_states(self): payload = {"door": 0} state = normalize_state(payload) self.assertEqual(state.door_state, "closed") payload = {"door": 1} state = normalize_state(payload) self.assertEqual(state.door_state, "open") payload = {"door": 2} state = normalize_state(payload) self.assertEqual(state.door_state, "opening") payload = {"door": 3} state = normalize_state(payload) self.assertEqual(state.door_state, "closing") payload = {"door": 4} state = normalize_state(payload) self.assertEqual(state.door_state, "stopped") def test_unknown_door_state(self): payload = {"door": 99} state = normalize_state(payload) self.assertEqual(state.door_state, "unknown") def test_optional_fields_and_capabilities(self): payload = { "door": 1, "secv": 2, "has_swrx": 1, "light": 1, "lock": 0, "obstruct": 1, "nopenings": 42, "pemu": 7, } state = normalize_state(payload) self.assertTrue(state.capabilities["security_plus"]) self.assertTrue(state.capabilities["light_control"]) self.assertTrue(state.capabilities["lock_control"]) self.assertTrue(state.capabilities["obstruction"]) self.assertTrue(state.capabilities["openings_counter"]) self.assertTrue(state.capabilities["pemu"]) self.assertEqual(state.nopenings, 42) self.assertEqual(state.pemu, 7) def test_non_dict_payload_is_safe(self): state = normalize_state("not-a-dict") self.assertIsInstance(state, NormalizedState) self.assertEqual(state.door_state, "unknown") self.assertIn("_raw", state.raw) if __name__ == "__main__": unittest.main() ``` ```python DOOR_STATE_MAP = { 0: "closed", 1: "open", 2: "opening", 3: "closing", 4: "stopped", } def _coerce_int(value): if value is None: return None try: return int(value) except (TypeError, ValueError): return None def _coerce_bool(value): if value is None: return None try: return bool(int(value)) except (TypeError, ValueError): return None class NormalizedState: def __init__( self, door_state, door_value, light_on=None, lock_engaged=None, obstruction=None, nopenings=None, secv=None, has_swrx=None, pemu=None, raw=None, ): self.door_state = door_state self.door_value = door_value self.light_on = light_on self.lock_engaged = lock_engaged self.obstruction = obstruction self.nopenings = nopenings self.secv = secv self.has_swrx = has_swrx self.pemu = pemu self.raw = raw if raw is not None else {} self.capabilities = { "security_plus": secv is not None or has_swrx is not None, "light_control": light_on is not None, "lock_control": lock_engaged is not None, "obstruction": obstruction is not None, "openings_counter": nopenings is not None, "pemu": pemu is not None, } def to_dict(self): return { "door_state": self.door_state, "door_value": self.door_value, "light_on": self.light_on, "lock_engaged": self.lock_engaged, "obstruction": self.obstruction, "nopenings": self.nopenings, "secv": self.secv, "has_swrx": self.has_swrx, "pemu": self.pemu, "capabilities": dict(self.capabilities), "raw": dict(self.raw) if isinstance(self.raw, dict) else self.raw, } def normalize_state(payload): if not isinstance(payload, dict): return NormalizedState("unknown", None, raw={"_raw": payload}) door_value = _coerce_int(payload.get("door")) door_state = DOOR_STATE_MAP.get(door_value, "unknown") secv = _coerce_int(payload.get("secv")) has_swrx = _coerce_bool(payload.get("has_swrx")) light_on = _coerce_bool(payload.get("light")) lock_engaged = _coerce_bool(payload.get("lock")) obstruction = _coerce_bool(payload.get("obstruct")) nopenings = _coerce_int(payload.get("nopenings")) pemu = _coerce_int(payload.get("pemu")) return NormalizedState( door_state=door_state, door_value=door_value, light_on=light_on, lock_engaged=lock_engaged, obstruction=obstruction, nopenings=nopenings, secv=secv, has_swrx=has_swrx, pemu=pemu, raw=payload, ) ``` **Verify:** `python -m unittest tests/test_state.py` **Commit:** `feat(state): add normalized state and capabilities` --- ### Task 1.3: Command Dispatcher **File:** `opengarage/dispatcher.py` **Test:** `tests/test_dispatcher.py` **Depends:** none ```python import unittest from opengarage.dispatcher import CommandDispatcher class TestCommandDispatcher(unittest.TestCase): def test_build_command(self): command = CommandDispatcher.build_command("open", "abc123") self.assertEqual(command, "cc?dkey=abc123&open=1") def test_invalid_action_raises(self): with self.assertRaises(ValueError): CommandDispatcher.build_command("invalid", "abc123") if __name__ == "__main__": unittest.main() ``` ```python class CommandDispatcher: """Centralized cc command construction.""" _ALLOWED_ACTIONS = { "click", "close", "open", "reboot", "apmode", "light", "lock", } @classmethod def build_command(cls, action, devkey): if action not in cls._ALLOWED_ACTIONS: raise ValueError("Unsupported action: %s" % action) return "cc?dkey=%s&%s=1" % (devkey, action) ``` **Verify:** `python -m unittest tests/test_dispatcher.py` **Commit:** `feat(core): add command dispatcher` --- ## Batch 2: Core Client Updates (parallel - 1 implementer) All tasks in this batch depend on Batch 1 completing. ### Task 2.1: OpenGarage Client Enhancements **File:** `opengarage/__init__.py` **Test:** `tests/test_client.py` **Depends:** 1.1, 1.2, 1.3 (imports errors, state, dispatcher) ```python import asyncio import unittest from opengarage import OpenGarage from opengarage.errors import ResponseError, UnsupportedFeatureError class FakeResponse: def __init__(self, status, payload=None, text=None): self.status = status self._payload = payload self._text = text or "" async def json(self, content_type=None): if isinstance(self._payload, Exception): raise self._payload return self._payload async def text(self): return self._text class FakeSession: def __init__(self, responses): self._responses = list(responses) async def get(self, url, verify_ssl=False): return self._responses.pop(0) async def close(self): return None class TestOpenGarageClient(unittest.TestCase): def test_get_state_normalizes(self): session = FakeSession([FakeResponse(200, {"door": 1, "light": 1})]) client = OpenGarage("http://device", "key", websession=session) loop = asyncio.get_event_loop() state = loop.run_until_complete(client.get_state()) self.assertEqual(state.door_state, "open") self.assertTrue(state.capabilities["light_control"]) def test_get_state_non_200_raises(self): session = FakeSession([FakeResponse(500, {"error": "bad"})]) client = OpenGarage("http://device", "key", websession=session) loop = asyncio.get_event_loop() with self.assertRaises(ResponseError): loop.run_until_complete(client.get_state()) def test_update_state_preserves_backward_behavior(self): session = FakeSession([FakeResponse(500, {"error": "bad"})]) client = OpenGarage("http://device", "key", websession=session) loop = asyncio.get_event_loop() result = loop.run_until_complete(client.update_state()) self.assertIsNone(result) def test_toggle_light_unsupported(self): session = FakeSession([FakeResponse(200, {"door": 1})]) client = OpenGarage("http://device", "key", websession=session) loop = asyncio.get_event_loop() with self.assertRaises(UnsupportedFeatureError): loop.run_until_complete(client.toggle_light()) def test_toggle_light_supported(self): session = FakeSession( [ FakeResponse(200, {"door": 1, "light": 1}), FakeResponse(200, {"result": "success"}), ] ) client = OpenGarage("http://device", "key", websession=session) loop = asyncio.get_event_loop() result = loop.run_until_complete(client.toggle_light()) self.assertEqual(result, "success") if __name__ == "__main__": unittest.main() ``` ```python """Open garage""" import asyncio import logging import aiohttp import async_timeout from opengarage.dispatcher import CommandDispatcher from opengarage.errors import ResponseError, TransportError, UnsupportedFeatureError from opengarage.state import normalize_state DEFAULT_TIMEOUT = 10 _LOGGER = logging.getLogger(__name__) class OpenGarage: """Class to communicate with the Open Garage api.""" # pylint: disable=too-many-arguments def __init__( self, devip, devkey, verify_ssl=False, websession=None, timeout=DEFAULT_TIMEOUT, ): """Initialize the Open Garage connection.""" if websession is None: async def _create_session(): return aiohttp.ClientSession() loop = asyncio.get_event_loop() self.websession = loop.run_until_complete(_create_session()) else: self.websession = websession self._timeout = timeout self._devip = devip self._devkey = devkey self._verify_ssl = verify_ssl @property def device_url(self): """Device url.""" return self._devip async def close_connection(self): """Close the connection.""" await self.websession.close() async def update_state(self): """Update state (raw jc payload).""" return await self._execute("jc") async def get_state(self): """Get normalized state plus raw payload.""" raw = await self._execute("jc", wrap_errors=True) return normalize_state(raw) async def get_capabilities(self): """Return capability flags from normalized state.""" state = await self.get_state() return dict(state.capabilities) async def push_button(self): """Push button.""" return await self._dispatch_action("click") async def push_close_button(self): """Push close button. No-op if already closed.""" return await self._dispatch_action("close") async def push_open_button(self): """Push open button. No-op if already open.""" return await self._dispatch_action("open") async def reboot(self): """Reboot device.""" return await self._dispatch_action("reboot") async def ap_mode(self): """Reset device in AP mode (to reconfigure WiFi settings).""" return await self._dispatch_action("apmode") async def toggle_light(self): """Toggle light when supported by firmware.""" state = await self.get_state() if not state.capabilities.get("light_control"): raise UnsupportedFeatureError("Light control not supported") return await self._dispatch_action("light", wrap_errors=True) async def toggle_lock(self): """Toggle lock when supported by firmware.""" state = await self.get_state() if not state.capabilities.get("lock_control"): raise UnsupportedFeatureError("Lock control not supported") return await self._dispatch_action("lock", wrap_errors=True) async def _dispatch_action(self, action, wrap_errors=False): command = CommandDispatcher.build_command(action, self._devkey) result = await self._execute(command, wrap_errors=wrap_errors) if result is None: return None return result.get("result") async def _execute(self, command, retry=2, wrap_errors=False): """Execute command.""" url = "%s/%s" % (self._devip, command) try: async with async_timeout.timeout(self._timeout): resp = await self.websession.get(url, verify_ssl=self._verify_ssl) if resp.status != 200: _LOGGER.error( "Error connecting to Open garage, resp code: %s", resp.status ) if wrap_errors: raise ResponseError(resp.status, url) return None try: result = await resp.json(content_type=None) except ValueError: # Malformed JSON; preserve raw payload for diagnostics text = await resp.text() result = {"_error": "invalid_json", "_raw": text} except aiohttp.ClientError as err: if retry > 0: return await self._execute(command, retry - 1, wrap_errors=wrap_errors) _LOGGER.error("Error connecting to Open garage: %s ", err, exc_info=True) if wrap_errors: raise TransportError(str(err)) raise except asyncio.TimeoutError as err: if retry > 0: return await self._execute(command, retry - 1, wrap_errors=wrap_errors) _LOGGER.error("Timed out when connecting to Open garage device") if wrap_errors: raise TransportError(str(err)) raise return result ``` **Verify:** `python -m unittest tests/test_client.py` **Commit:** `feat(client): add normalized state and capability-gated actions` --- ## Notes / Design-to-Implementation Decisions - **Backward compatibility preserved:** existing methods (`update_state`, `push_*`) keep returning raw results or `None` for non-200 responses. New capability-aware methods (`get_state`, `get_capabilities`, `toggle_light`, `toggle_lock`) use structured errors without changing legacy behavior. - **Malformed JSON handling:** `_execute` now captures invalid JSON and returns a safe `{"_error": "invalid_json", "_raw": "..."}` dict. This enables state normalization to proceed without crashing, matching the design’s requirement to preserve diagnostics. - **Capability detection:** implemented in `NormalizedState.capabilities` based on optional field presence, so older firmware remains “not supported” rather than failing. - **Door state mapping:** uses a stable mapping for known values and defaults to `"unknown"` for future values to avoid breaking changes.