Loading noctua/api/baseresource.py +7 −16 Original line number Diff line number Diff line Loading @@ -7,8 +7,7 @@ from quart import request, jsonify from quart.views import MethodView # Custom modules from .deps import check_dependencies from .deps import dep_checker # Change this import class BaseResource(MethodView): """ Loading Loading @@ -109,26 +108,19 @@ class BaseResource(MethodView): async def dispatch_request(self, *args, **kwargs): """ Override the default dispatch to enforce dependency checks and validate method existence before execution. Returns ------- Response The Quart response object or a JSON error. Override the default dispatch to enforce cached dependency checks. """ # Determine the logical path for dependency checking api_path = request.path.replace("/api", "", 1) # 1. Validate that the class implements the requested HTTP method handler = getattr(self, request.method.lower(), None) if handler is None: from quart import abort abort(405) # 2. Validate the dependency chain (reads ?force=true from query string) force = request.args.get('force') == 'true' is_available, err_msg = await check_dependencies(api_path, force) # Use dep_checker instead of check_dependencies is_available, err_msg = await dep_checker.get_status(api_path, force) if not is_available: return jsonify({ Loading @@ -138,7 +130,6 @@ class BaseResource(MethodView): "timestamp": self.timestamp, }), 424 # 3. Proceed to standard MethodView dispatching return await super().dispatch_request(*args, **kwargs) Loading noctua/api/deps.py +87 −36 Original line number Diff line number Diff line #!/usr/bin/env python3 # -*- coding: utf-8 -*- # System modules import time '''Shared dependency resolution for API resources and the stream layer.''' # Third-party modules # Custom modules from noctua.api.basecontext import ends, resource_registry async def check_dependencies(path: str, force: bool = False) -> tuple[bool, str | None]: class DependencyCache: """ Recursively verify the dependency chain for a given endpoint path. Manages dependency verification with a short-lived internal cache. """ def __init__(self, ttl=0.8): """ Initialize the dependency cache. Parameters ---------- ttl : float Time-to-live for cache entries in seconds. """ self._cache = {} self._ttl = ttl Walks the ``depends-on`` chain declared in ``api.ini`` and calls each parent resource's ``get()`` method directly, so the check always reflects the live hardware state rather than any cached value. async def get_status(self, path, force=False): """ Check if a resource is available based on its dependencies. Parameters ---------- path : str The API endpoint path to check (e.g. ``/camera/snapshot``). force : bool, optional When ``True`` the entire dependency chain is bypassed and the function returns ``(True, None)`` immediately. Defaults to ``False``. The API path to verify. force : bool If True, bypass all checks. Returns ------- tuple[bool, str | None] ``(True, None)`` if the chain is satisfied, or ``(False, error_message)`` at the first failing link. tuple A tuple containing (is_available, error_message). """ if force: return True, None now = time.time() if path in self._cache: status, timestamp = self._cache[path] if (now - timestamp) < self._ttl: return status result = await self._perform_check(path, force) self._cache[path] = (result, now) return result async def _perform_check(self, path, force): """ Logic for recursive dependency resolution. Parameters ---------- path : str The API path. force : bool The force flag. Returns ------- tuple The check result. """ dependency = ends.get(path, "depends-on", fallback=None) if not dependency: Loading @@ -41,12 +83,21 @@ async def check_dependencies(path: str, force: bool = False) -> tuple[bool, str parent_resource = resource_registry.get(dependency) if not parent_resource: return False, f"Dependency configuration error: {dependency} not found in registry" return False, f"Missing resource: {dependency}" # Recursive call to resolve the parent's own dependencies parent_ok, parent_err = await self.get_status(dependency, force) if not parent_ok: return False, parent_err # Check parent actual state data, status_code = await parent_resource.get() if status_code != 200 or not data.get("raw") or data.get("error"): return False, f"Dependency failed: parent {dependency} is OFF or in error state" return False, f"Link failure: {dependency}" return True, None # Recurse up the chain return await check_dependencies(dependency, force) # Singleton instance dep_checker = DependencyCache() noctua/devices/alpaca.py +2 −1 Original line number Diff line number Diff line Loading @@ -76,6 +76,7 @@ class AlpacaDevice(BaseDevice): res.raise_for_status() resj = res.json() if resj["ErrorNumber"] != 0: msg = f'ASCOM {resj["ErrorNumber"]}: {resj["ErrorMessage"]}' log.error(msg) Loading noctua/devices/netio.py +13 −58 Original line number Diff line number Diff line Loading @@ -34,6 +34,7 @@ class PowerPDU: verify=False) # params=base_params, # log.debug(res) res.raise_for_status() resj = res.json() outputs = resj['Outputs'] Loading Loading @@ -109,6 +110,7 @@ class Switch(PowerPDU): @property def state(self): resj = self.get(id=self.id) if resj: try: res = resj[0]['State'] self._state = True if res == 1 else False Loading @@ -116,59 +118,12 @@ class Switch(PowerPDU): except IndexError as e: log.error("No valid response") return None except Exception as e: log.error("Generic exception") return None @state.setter def state(self, s): switchcmd = 1 if s else 0 res = self.put(switchcmd, id=self.id) self._state = self.state # @property # def all(self): # res = self.get() # return res # class Switch(Sonoff): # def __init__(self, url, id): # super().__init__(url, id) # def reboot(self): # while self.state: # self.state = False # while not self.state: # self.state = True # return self.state # @property # def state(self): # res = self.get("Status", id=self.id) # self._state = True if res == 'On' else False # return self._state # @state.setter # def state(self, s): # switchcmd = 'On' if s else 'Off' # params = {"param": "switchlight", "switchcmd": switchcmd} # res = self.put(params, id=self.id) # self._state = self.state # class Sensor(Sonoff): # def __init__(self, url, temp_id, hum_id): # super().__init__(url, temp_id) # self.id = temp_id # recycle for last_update # self.hum_id = hum_id # @property # def temperature(self): # res = self.get("Temp", id=self.id) # return res # @property # def humidity(self): # res = self.get("Humidity", id=self.hum_id) # return res noctua/utils/check.py +10 −2 Original line number Diff line number Diff line Loading @@ -164,6 +164,14 @@ def request_errors(func): this.error.append(str(e)) return except requests.exceptions.JSONDecodeError as e: msg = f"{name}: Probably malformed JSON!" log.error(msg) log.error(e) this.error.append(msg) this.error.append(str(e)) return except requests.exceptions.RequestException as e: msg = f"{name}: Generic request exception!" log.error(msg) Loading Loading
noctua/api/baseresource.py +7 −16 Original line number Diff line number Diff line Loading @@ -7,8 +7,7 @@ from quart import request, jsonify from quart.views import MethodView # Custom modules from .deps import check_dependencies from .deps import dep_checker # Change this import class BaseResource(MethodView): """ Loading Loading @@ -109,26 +108,19 @@ class BaseResource(MethodView): async def dispatch_request(self, *args, **kwargs): """ Override the default dispatch to enforce dependency checks and validate method existence before execution. Returns ------- Response The Quart response object or a JSON error. Override the default dispatch to enforce cached dependency checks. """ # Determine the logical path for dependency checking api_path = request.path.replace("/api", "", 1) # 1. Validate that the class implements the requested HTTP method handler = getattr(self, request.method.lower(), None) if handler is None: from quart import abort abort(405) # 2. Validate the dependency chain (reads ?force=true from query string) force = request.args.get('force') == 'true' is_available, err_msg = await check_dependencies(api_path, force) # Use dep_checker instead of check_dependencies is_available, err_msg = await dep_checker.get_status(api_path, force) if not is_available: return jsonify({ Loading @@ -138,7 +130,6 @@ class BaseResource(MethodView): "timestamp": self.timestamp, }), 424 # 3. Proceed to standard MethodView dispatching return await super().dispatch_request(*args, **kwargs) Loading
noctua/api/deps.py +87 −36 Original line number Diff line number Diff line #!/usr/bin/env python3 # -*- coding: utf-8 -*- # System modules import time '''Shared dependency resolution for API resources and the stream layer.''' # Third-party modules # Custom modules from noctua.api.basecontext import ends, resource_registry async def check_dependencies(path: str, force: bool = False) -> tuple[bool, str | None]: class DependencyCache: """ Recursively verify the dependency chain for a given endpoint path. Manages dependency verification with a short-lived internal cache. """ def __init__(self, ttl=0.8): """ Initialize the dependency cache. Parameters ---------- ttl : float Time-to-live for cache entries in seconds. """ self._cache = {} self._ttl = ttl Walks the ``depends-on`` chain declared in ``api.ini`` and calls each parent resource's ``get()`` method directly, so the check always reflects the live hardware state rather than any cached value. async def get_status(self, path, force=False): """ Check if a resource is available based on its dependencies. Parameters ---------- path : str The API endpoint path to check (e.g. ``/camera/snapshot``). force : bool, optional When ``True`` the entire dependency chain is bypassed and the function returns ``(True, None)`` immediately. Defaults to ``False``. The API path to verify. force : bool If True, bypass all checks. Returns ------- tuple[bool, str | None] ``(True, None)`` if the chain is satisfied, or ``(False, error_message)`` at the first failing link. tuple A tuple containing (is_available, error_message). """ if force: return True, None now = time.time() if path in self._cache: status, timestamp = self._cache[path] if (now - timestamp) < self._ttl: return status result = await self._perform_check(path, force) self._cache[path] = (result, now) return result async def _perform_check(self, path, force): """ Logic for recursive dependency resolution. Parameters ---------- path : str The API path. force : bool The force flag. Returns ------- tuple The check result. """ dependency = ends.get(path, "depends-on", fallback=None) if not dependency: Loading @@ -41,12 +83,21 @@ async def check_dependencies(path: str, force: bool = False) -> tuple[bool, str parent_resource = resource_registry.get(dependency) if not parent_resource: return False, f"Dependency configuration error: {dependency} not found in registry" return False, f"Missing resource: {dependency}" # Recursive call to resolve the parent's own dependencies parent_ok, parent_err = await self.get_status(dependency, force) if not parent_ok: return False, parent_err # Check parent actual state data, status_code = await parent_resource.get() if status_code != 200 or not data.get("raw") or data.get("error"): return False, f"Dependency failed: parent {dependency} is OFF or in error state" return False, f"Link failure: {dependency}" return True, None # Recurse up the chain return await check_dependencies(dependency, force) # Singleton instance dep_checker = DependencyCache()
noctua/devices/alpaca.py +2 −1 Original line number Diff line number Diff line Loading @@ -76,6 +76,7 @@ class AlpacaDevice(BaseDevice): res.raise_for_status() resj = res.json() if resj["ErrorNumber"] != 0: msg = f'ASCOM {resj["ErrorNumber"]}: {resj["ErrorMessage"]}' log.error(msg) Loading
noctua/devices/netio.py +13 −58 Original line number Diff line number Diff line Loading @@ -34,6 +34,7 @@ class PowerPDU: verify=False) # params=base_params, # log.debug(res) res.raise_for_status() resj = res.json() outputs = resj['Outputs'] Loading Loading @@ -109,6 +110,7 @@ class Switch(PowerPDU): @property def state(self): resj = self.get(id=self.id) if resj: try: res = resj[0]['State'] self._state = True if res == 1 else False Loading @@ -116,59 +118,12 @@ class Switch(PowerPDU): except IndexError as e: log.error("No valid response") return None except Exception as e: log.error("Generic exception") return None @state.setter def state(self, s): switchcmd = 1 if s else 0 res = self.put(switchcmd, id=self.id) self._state = self.state # @property # def all(self): # res = self.get() # return res # class Switch(Sonoff): # def __init__(self, url, id): # super().__init__(url, id) # def reboot(self): # while self.state: # self.state = False # while not self.state: # self.state = True # return self.state # @property # def state(self): # res = self.get("Status", id=self.id) # self._state = True if res == 'On' else False # return self._state # @state.setter # def state(self, s): # switchcmd = 'On' if s else 'Off' # params = {"param": "switchlight", "switchcmd": switchcmd} # res = self.put(params, id=self.id) # self._state = self.state # class Sensor(Sonoff): # def __init__(self, url, temp_id, hum_id): # super().__init__(url, temp_id) # self.id = temp_id # recycle for last_update # self.hum_id = hum_id # @property # def temperature(self): # res = self.get("Temp", id=self.id) # return res # @property # def humidity(self): # res = self.get("Humidity", id=self.hum_id) # return res
noctua/utils/check.py +10 −2 Original line number Diff line number Diff line Loading @@ -164,6 +164,14 @@ def request_errors(func): this.error.append(str(e)) return except requests.exceptions.JSONDecodeError as e: msg = f"{name}: Probably malformed JSON!" log.error(msg) log.error(e) this.error.append(msg) this.error.append(str(e)) return except requests.exceptions.RequestException as e: msg = f"{name}: Generic request exception!" log.error(msg) Loading