Loading noctua/api/__init__.py +44 −24 Original line number Diff line number Diff line Loading @@ -9,16 +9,18 @@ import importlib from pathlib import Path # Third-party modules from quart import Blueprint, request, abort, jsonify from quart import Blueprint, abort, jsonify, request # Custom modules # Custom modules (Noctua Core) from noctua import devices from noctua.api.basecontext import config_path, ends, resource_registry from noctua.api.sequencer_instance import seq from noctua.api.basecontext import ends, resource_registry, config_path from noctua.utils.logger import log # Other templates from .blocks import blocks_api from .defaults import defaults_api from .sequencer import sequencer_api, BobRun from .sequencer import BobRun, sequencer_api api_blueprint = Blueprint('api', __name__) Loading @@ -42,14 +44,26 @@ def dynamic_import(url_path): if url_path.startswith(("/blocks", "/templates", "/sequencer")): return dev = getattr(devices, ends.get(url_path,"device")) # devices.light instance dev = getattr(devices, ends.get(url_path, "device") ) # devices.light instance cls = dev.__class__.__name__ # string "Switch" mod_name = cls.lower() # string "switch" module = importlib.import_module(f"noctua.api.{mod_name}") # module noctua.api.switch resource_class = getattr(module, ends.get(url_path,"resource")) # class noctua.api.switch.State # Identify supported HTTP methods by checking for lowercase method names in the class methods = [m for m in ["GET", "POST", "PUT", "DELETE"] if hasattr(resource_class, m.lower())] # string "switch" mod_name = cls.lower() module = importlib.import_module( f"noctua.api.{mod_name}") # module noctua.api.switch # class noctua.api.switch.State resource_class = getattr(module, ends.get(url_path, "resource")) # Identify supported HTTP methods by checking for lowercase method # names in the class methods = [ m for m in [ "GET", "POST", "PUT", "DELETE"] if hasattr( resource_class, m.lower())] # Populate internal registry resource_registry[url_path] = resource_class(dev=dev) Loading @@ -60,11 +74,16 @@ def dynamic_import(url_path): # Improved debug print with methods list full_resource_path = f"{module.__name__}.{resource_class.__name__}" log.info(f"Route registered: /api{url_path:<40} {full_resource_path:<48} {str(methods):<25}") log.info( f"Route registered: /api{ url_path:<40} { full_resource_path:<48} { str(methods):<25}") except Exception as e: log.warning(f"Error loading route: {url_path:<40} {e}") # Load all routes from ini for section in ends.sections(): if not section.startswith(("/blocks", "/templates", "/sequencer")): Loading @@ -85,7 +104,8 @@ async def api_catalog(): method_fn = getattr(resource_instance, m.lower(), None) if method_fn: # Estraiamo i metadati del decoratore se presenti, altrimenti None # Estraiamo i metadati del decoratore se presenti, altrimenti # None schema = getattr(method_fn, "_input_schema", None) formatted_endpoint = f"/api/{url_path.strip('/')}/" Loading noctua/api/basecontext.py +1 −0 Original line number Diff line number Diff line # System modules import configparser from pathlib import Path Loading noctua/api/baseresource.py +26 −14 Original line number Diff line number Diff line #!/usr/bin/env python3 # -*- coding: utf-8 -*- # System modules import asyncio from datetime import datetime from quart import request, jsonify # Third-party modules from quart import jsonify, request from quart.views import MethodView # Custom modules # Other templates from .deps import dep_checker # Change this import class BaseResource(MethodView): """ Base class for all API resources, providing dependency management and Loading Loading @@ -71,7 +75,12 @@ class BaseResource(MethodView): """ return await asyncio.to_thread(func, *args, **kwargs) def make_response(self, response_data, raw_data=None, errors=None, status_code=200): def make_response( self, response_data, raw_data=None, errors=None, status_code=200): """ Build a standardized JSON response dict. Loading Loading @@ -114,6 +123,7 @@ class BaseResource(MethodView): handler = getattr(self, request.method.lower(), None) if handler is None: # Third-party modules from quart import abort abort(405) Loading Loading @@ -144,21 +154,23 @@ def register_error_handlers(app): """ @app.errorhandler(400) async def bad_request(e): return jsonify({"error": ["Bad Request"], "timestamp": datetime.utcnow().isoformat()}), 400 return jsonify( {"error": ["Bad Request"], "timestamp": datetime.utcnow().isoformat()}), 400 @app.errorhandler(404) async def not_found(e): return jsonify({"error": ["URL not found"], "timestamp": datetime.utcnow().isoformat()}), 404 return jsonify( {"error": ["URL not found"], "timestamp": datetime.utcnow().isoformat()}), 404 @app.errorhandler(405) async def method_not_allowed(e): return jsonify({"error": ["Method not allowed"], "timestamp": datetime.utcnow().isoformat()}), 405 return jsonify({"error": ["Method not allowed"], "timestamp": datetime.utcnow().isoformat()}), 405 @app.errorhandler(424) async def failed_dependency(e): return jsonify({"error": ["Failed Dependency"], "timestamp": datetime.utcnow().isoformat()}), 424 return jsonify({"error": ["Failed Dependency"], "timestamp": datetime.utcnow().isoformat()}), 424 def expects(param_type="single", count=1, unit=None, placeholder=None): Loading noctua/api/blocks.py +22 −21 Original line number Diff line number Diff line Loading @@ -30,8 +30,8 @@ class BlockFile(MethodView): """Show the whole OB content.""" content = await dao.read(name) return content if content is not None else ({"error": "OB not found"}, 404) return content if content is not None else ( {"error": "OB not found"}, 404) async def post(self, name): """ Loading @@ -42,12 +42,14 @@ class BlockFile(MethodView): data = await request.get_json(force=True, silent=True) # Logic to distinguish between "Create from Template" and "Save Editor Data" # Logic to distinguish between "Create from Template" and "Save Editor # Data" if isinstance(data, str): # It's a template name (e.g. "observation") tpl_content = await dao.read_default(data) if tpl_content: final_data = [tpl_content] if not isinstance(tpl_content, list) else tpl_content final_data = [tpl_content] if not isinstance( tpl_content, list) else tpl_content else: return {"error": f"Template {data} not found"}, 404 elif isinstance(data, (dict, list)): Loading @@ -60,7 +62,6 @@ class BlockFile(MethodView): await dao.write(name, final_data) return final_data, 201 async def put(self, name): """Append a template from defaults to the existing OB.""" Loading @@ -81,7 +82,6 @@ class BlockFile(MethodView): return {"error": f"Template '{tpl_name}' not found in defaults"}, 404 async def delete(self, name): """Delete the whole OB file.""" Loading @@ -103,7 +103,6 @@ class BlockElement(MethodView): except (IndexError, TypeError, KeyError): return {"error": "Index out of range or OB not found"}, 404 async def put(self, name, index): """Update a specific template inside the OB.""" Loading @@ -116,7 +115,6 @@ class BlockElement(MethodView): except (IndexError, TypeError, KeyError): return {"error": "Index out of range or OB not found"}, 404 async def delete(self, name, index): """Delete a specific template inside the OB.""" Loading @@ -132,5 +130,8 @@ class BlockElement(MethodView): # --- ROUTING --- blocks_api.add_url_rule('/', view_func=BlocksList.as_view('blocks_list')) blocks_api.add_url_rule('/<name>', view_func=BlockFile.as_view('block_file')) blocks_api.add_url_rule('/<name>/', view_func=BlockFile.as_view('block_file_read')) blocks_api.add_url_rule('/<name>/<int:index>', view_func=BlockElement.as_view('block_element')) blocks_api.add_url_rule( '/<name>/', view_func=BlockFile.as_view('block_file_read')) blocks_api.add_url_rule('/<name>/<int:index>', view_func=BlockElement.as_view('block_element')) noctua/api/camera.py +58 −33 Original line number Diff line number Diff line Loading @@ -4,10 +4,14 @@ '''REST API for Camera related operations''' # Custom modules (Noctua Core) from noctua.api.sequencer_instance import seq from noctua.config import constants # Other templates # This module imports from .baseresource import BaseResource, expects from noctua.config import constants from noctua.api.sequencer_instance import seq class FrameBinning(BaseResource): """Binning of the camera.""" Loading @@ -17,12 +21,14 @@ class FrameBinning(BaseResource): """Set a new binning for the camera.""" binning = await self.get_payload() def action(): self.dev.binning = binning return self.dev.binning res = await self.run_blocking(action) return self.make_response(res) class Cooler(BaseResource): """Manage the CCD cooler status""" Loading @@ -38,12 +44,14 @@ class Cooler(BaseResource): """Set on or off the CCD cooler.""" state = await self.get_payload() def action(): self.dev.cooler = state return self.dev.cooler res = await self.run_blocking(action) return self.make_response(res) class CoolerTemperatureSetpoint(BaseResource): """Manage the CCD temperature""" Loading @@ -52,12 +60,14 @@ class CoolerTemperatureSetpoint(BaseResource): """Set a new temperature of the CCD.""" state = await self.get_payload() def action(): self.dev.temperature = state return self.dev.temperature res = await self.run_blocking(action) return self.make_response(res) class CoolerWarmup(BaseResource): """Manage the warmup of the CCD.""" Loading @@ -71,6 +81,7 @@ class CoolerWarmup(BaseResource): return self.make_response("Warmup sequence aborted") class Filters(BaseResource): """Camera filters names.""" Loading @@ -81,6 +92,7 @@ class Filters(BaseResource): res = constants.filter_number return self.make_response(res) class Filter(BaseResource): """Camera filter information.""" Loading @@ -92,6 +104,7 @@ class Filter(BaseResource): res = constants.filter_name.get(raw, "Undef.") return self.make_response(res, raw_data=raw) class FilterMovement(BaseResource): """Manage the camera filter wheel.""" Loading @@ -108,12 +121,14 @@ class FilterMovement(BaseResource): """Set a new filter.""" target = await self.get_payload() def action(): self.dev.filter = target return self.dev.filter res = await self.run_blocking(action) return self.make_response(res) class FrameCustom(BaseResource): """Camera custom frame.""" Loading @@ -122,6 +137,7 @@ class FrameCustom(BaseResource): """Set a custom windowing.""" new_frame = await self.get_payload() def action(): self.dev.binning = new_frame["binning"] # Logic depends on driver implementation of set_window Loading @@ -129,6 +145,7 @@ class FrameCustom(BaseResource): res = await self.run_blocking(action) return self.make_response(res) class FrameFull(BaseResource): """Camera full frame.""" Loading @@ -138,6 +155,7 @@ class FrameFull(BaseResource): res = await self.run_blocking(self.dev.full_frame) return self.make_response(res) class FrameHalf(BaseResource): """Camera frame of half size the full frame.""" Loading @@ -148,6 +166,7 @@ class FrameHalf(BaseResource): res = await self.run_blocking(self.dev.half_frame) return self.make_response(res) class FrameSmall(BaseResource): """Camera frame of 2 arcmin.""" Loading @@ -158,6 +177,7 @@ class FrameSmall(BaseResource): res = await self.run_blocking(self.dev.small_frame) return self.make_response(res) class SnapshotRaw(BaseResource): """The acquired image.""" Loading Loading @@ -186,6 +206,7 @@ class SnapshotRaw(BaseResource): res = await self.run_blocking(self.dev.abort) return self.make_response(res) class SnapshotState(BaseResource): """Manage the state of a raw image.""" Loading @@ -196,6 +217,7 @@ class SnapshotState(BaseResource): res = constants.camera_state.get(raw, "Off") return self.make_response(res, raw_data=raw) class Snapshot(BaseResource): """Manage the acquisition of an image using the sequencer.""" Loading @@ -221,6 +243,7 @@ class Snapshot(BaseResource): res = await self.run_blocking(seq.tpl.abort) return self.make_response(res) class SnapshotRecenter(BaseResource): """Manage the recentering via the the apply_offset function""" Loading Loading @@ -255,6 +278,7 @@ class SnapshotRecenter(BaseResource): seq.tpl.recenter = False return self.make_response(False) class SnapshotDomeslewing(BaseResource): """Get dome slewing status""" Loading Loading @@ -288,6 +312,7 @@ class Connection(BaseResource): res = await self.run_blocking(lambda: self.dev.connection) return self.make_response(res) class Settings(BaseResource): '''General camera settings.''' Loading Loading
noctua/api/__init__.py +44 −24 Original line number Diff line number Diff line Loading @@ -9,16 +9,18 @@ import importlib from pathlib import Path # Third-party modules from quart import Blueprint, request, abort, jsonify from quart import Blueprint, abort, jsonify, request # Custom modules # Custom modules (Noctua Core) from noctua import devices from noctua.api.basecontext import config_path, ends, resource_registry from noctua.api.sequencer_instance import seq from noctua.api.basecontext import ends, resource_registry, config_path from noctua.utils.logger import log # Other templates from .blocks import blocks_api from .defaults import defaults_api from .sequencer import sequencer_api, BobRun from .sequencer import BobRun, sequencer_api api_blueprint = Blueprint('api', __name__) Loading @@ -42,14 +44,26 @@ def dynamic_import(url_path): if url_path.startswith(("/blocks", "/templates", "/sequencer")): return dev = getattr(devices, ends.get(url_path,"device")) # devices.light instance dev = getattr(devices, ends.get(url_path, "device") ) # devices.light instance cls = dev.__class__.__name__ # string "Switch" mod_name = cls.lower() # string "switch" module = importlib.import_module(f"noctua.api.{mod_name}") # module noctua.api.switch resource_class = getattr(module, ends.get(url_path,"resource")) # class noctua.api.switch.State # Identify supported HTTP methods by checking for lowercase method names in the class methods = [m for m in ["GET", "POST", "PUT", "DELETE"] if hasattr(resource_class, m.lower())] # string "switch" mod_name = cls.lower() module = importlib.import_module( f"noctua.api.{mod_name}") # module noctua.api.switch # class noctua.api.switch.State resource_class = getattr(module, ends.get(url_path, "resource")) # Identify supported HTTP methods by checking for lowercase method # names in the class methods = [ m for m in [ "GET", "POST", "PUT", "DELETE"] if hasattr( resource_class, m.lower())] # Populate internal registry resource_registry[url_path] = resource_class(dev=dev) Loading @@ -60,11 +74,16 @@ def dynamic_import(url_path): # Improved debug print with methods list full_resource_path = f"{module.__name__}.{resource_class.__name__}" log.info(f"Route registered: /api{url_path:<40} {full_resource_path:<48} {str(methods):<25}") log.info( f"Route registered: /api{ url_path:<40} { full_resource_path:<48} { str(methods):<25}") except Exception as e: log.warning(f"Error loading route: {url_path:<40} {e}") # Load all routes from ini for section in ends.sections(): if not section.startswith(("/blocks", "/templates", "/sequencer")): Loading @@ -85,7 +104,8 @@ async def api_catalog(): method_fn = getattr(resource_instance, m.lower(), None) if method_fn: # Estraiamo i metadati del decoratore se presenti, altrimenti None # Estraiamo i metadati del decoratore se presenti, altrimenti # None schema = getattr(method_fn, "_input_schema", None) formatted_endpoint = f"/api/{url_path.strip('/')}/" Loading
noctua/api/basecontext.py +1 −0 Original line number Diff line number Diff line # System modules import configparser from pathlib import Path Loading
noctua/api/baseresource.py +26 −14 Original line number Diff line number Diff line #!/usr/bin/env python3 # -*- coding: utf-8 -*- # System modules import asyncio from datetime import datetime from quart import request, jsonify # Third-party modules from quart import jsonify, request from quart.views import MethodView # Custom modules # Other templates from .deps import dep_checker # Change this import class BaseResource(MethodView): """ Base class for all API resources, providing dependency management and Loading Loading @@ -71,7 +75,12 @@ class BaseResource(MethodView): """ return await asyncio.to_thread(func, *args, **kwargs) def make_response(self, response_data, raw_data=None, errors=None, status_code=200): def make_response( self, response_data, raw_data=None, errors=None, status_code=200): """ Build a standardized JSON response dict. Loading Loading @@ -114,6 +123,7 @@ class BaseResource(MethodView): handler = getattr(self, request.method.lower(), None) if handler is None: # Third-party modules from quart import abort abort(405) Loading Loading @@ -144,21 +154,23 @@ def register_error_handlers(app): """ @app.errorhandler(400) async def bad_request(e): return jsonify({"error": ["Bad Request"], "timestamp": datetime.utcnow().isoformat()}), 400 return jsonify( {"error": ["Bad Request"], "timestamp": datetime.utcnow().isoformat()}), 400 @app.errorhandler(404) async def not_found(e): return jsonify({"error": ["URL not found"], "timestamp": datetime.utcnow().isoformat()}), 404 return jsonify( {"error": ["URL not found"], "timestamp": datetime.utcnow().isoformat()}), 404 @app.errorhandler(405) async def method_not_allowed(e): return jsonify({"error": ["Method not allowed"], "timestamp": datetime.utcnow().isoformat()}), 405 return jsonify({"error": ["Method not allowed"], "timestamp": datetime.utcnow().isoformat()}), 405 @app.errorhandler(424) async def failed_dependency(e): return jsonify({"error": ["Failed Dependency"], "timestamp": datetime.utcnow().isoformat()}), 424 return jsonify({"error": ["Failed Dependency"], "timestamp": datetime.utcnow().isoformat()}), 424 def expects(param_type="single", count=1, unit=None, placeholder=None): Loading
noctua/api/blocks.py +22 −21 Original line number Diff line number Diff line Loading @@ -30,8 +30,8 @@ class BlockFile(MethodView): """Show the whole OB content.""" content = await dao.read(name) return content if content is not None else ({"error": "OB not found"}, 404) return content if content is not None else ( {"error": "OB not found"}, 404) async def post(self, name): """ Loading @@ -42,12 +42,14 @@ class BlockFile(MethodView): data = await request.get_json(force=True, silent=True) # Logic to distinguish between "Create from Template" and "Save Editor Data" # Logic to distinguish between "Create from Template" and "Save Editor # Data" if isinstance(data, str): # It's a template name (e.g. "observation") tpl_content = await dao.read_default(data) if tpl_content: final_data = [tpl_content] if not isinstance(tpl_content, list) else tpl_content final_data = [tpl_content] if not isinstance( tpl_content, list) else tpl_content else: return {"error": f"Template {data} not found"}, 404 elif isinstance(data, (dict, list)): Loading @@ -60,7 +62,6 @@ class BlockFile(MethodView): await dao.write(name, final_data) return final_data, 201 async def put(self, name): """Append a template from defaults to the existing OB.""" Loading @@ -81,7 +82,6 @@ class BlockFile(MethodView): return {"error": f"Template '{tpl_name}' not found in defaults"}, 404 async def delete(self, name): """Delete the whole OB file.""" Loading @@ -103,7 +103,6 @@ class BlockElement(MethodView): except (IndexError, TypeError, KeyError): return {"error": "Index out of range or OB not found"}, 404 async def put(self, name, index): """Update a specific template inside the OB.""" Loading @@ -116,7 +115,6 @@ class BlockElement(MethodView): except (IndexError, TypeError, KeyError): return {"error": "Index out of range or OB not found"}, 404 async def delete(self, name, index): """Delete a specific template inside the OB.""" Loading @@ -132,5 +130,8 @@ class BlockElement(MethodView): # --- ROUTING --- blocks_api.add_url_rule('/', view_func=BlocksList.as_view('blocks_list')) blocks_api.add_url_rule('/<name>', view_func=BlockFile.as_view('block_file')) blocks_api.add_url_rule('/<name>/', view_func=BlockFile.as_view('block_file_read')) blocks_api.add_url_rule('/<name>/<int:index>', view_func=BlockElement.as_view('block_element')) blocks_api.add_url_rule( '/<name>/', view_func=BlockFile.as_view('block_file_read')) blocks_api.add_url_rule('/<name>/<int:index>', view_func=BlockElement.as_view('block_element'))
noctua/api/camera.py +58 −33 Original line number Diff line number Diff line Loading @@ -4,10 +4,14 @@ '''REST API for Camera related operations''' # Custom modules (Noctua Core) from noctua.api.sequencer_instance import seq from noctua.config import constants # Other templates # This module imports from .baseresource import BaseResource, expects from noctua.config import constants from noctua.api.sequencer_instance import seq class FrameBinning(BaseResource): """Binning of the camera.""" Loading @@ -17,12 +21,14 @@ class FrameBinning(BaseResource): """Set a new binning for the camera.""" binning = await self.get_payload() def action(): self.dev.binning = binning return self.dev.binning res = await self.run_blocking(action) return self.make_response(res) class Cooler(BaseResource): """Manage the CCD cooler status""" Loading @@ -38,12 +44,14 @@ class Cooler(BaseResource): """Set on or off the CCD cooler.""" state = await self.get_payload() def action(): self.dev.cooler = state return self.dev.cooler res = await self.run_blocking(action) return self.make_response(res) class CoolerTemperatureSetpoint(BaseResource): """Manage the CCD temperature""" Loading @@ -52,12 +60,14 @@ class CoolerTemperatureSetpoint(BaseResource): """Set a new temperature of the CCD.""" state = await self.get_payload() def action(): self.dev.temperature = state return self.dev.temperature res = await self.run_blocking(action) return self.make_response(res) class CoolerWarmup(BaseResource): """Manage the warmup of the CCD.""" Loading @@ -71,6 +81,7 @@ class CoolerWarmup(BaseResource): return self.make_response("Warmup sequence aborted") class Filters(BaseResource): """Camera filters names.""" Loading @@ -81,6 +92,7 @@ class Filters(BaseResource): res = constants.filter_number return self.make_response(res) class Filter(BaseResource): """Camera filter information.""" Loading @@ -92,6 +104,7 @@ class Filter(BaseResource): res = constants.filter_name.get(raw, "Undef.") return self.make_response(res, raw_data=raw) class FilterMovement(BaseResource): """Manage the camera filter wheel.""" Loading @@ -108,12 +121,14 @@ class FilterMovement(BaseResource): """Set a new filter.""" target = await self.get_payload() def action(): self.dev.filter = target return self.dev.filter res = await self.run_blocking(action) return self.make_response(res) class FrameCustom(BaseResource): """Camera custom frame.""" Loading @@ -122,6 +137,7 @@ class FrameCustom(BaseResource): """Set a custom windowing.""" new_frame = await self.get_payload() def action(): self.dev.binning = new_frame["binning"] # Logic depends on driver implementation of set_window Loading @@ -129,6 +145,7 @@ class FrameCustom(BaseResource): res = await self.run_blocking(action) return self.make_response(res) class FrameFull(BaseResource): """Camera full frame.""" Loading @@ -138,6 +155,7 @@ class FrameFull(BaseResource): res = await self.run_blocking(self.dev.full_frame) return self.make_response(res) class FrameHalf(BaseResource): """Camera frame of half size the full frame.""" Loading @@ -148,6 +166,7 @@ class FrameHalf(BaseResource): res = await self.run_blocking(self.dev.half_frame) return self.make_response(res) class FrameSmall(BaseResource): """Camera frame of 2 arcmin.""" Loading @@ -158,6 +177,7 @@ class FrameSmall(BaseResource): res = await self.run_blocking(self.dev.small_frame) return self.make_response(res) class SnapshotRaw(BaseResource): """The acquired image.""" Loading Loading @@ -186,6 +206,7 @@ class SnapshotRaw(BaseResource): res = await self.run_blocking(self.dev.abort) return self.make_response(res) class SnapshotState(BaseResource): """Manage the state of a raw image.""" Loading @@ -196,6 +217,7 @@ class SnapshotState(BaseResource): res = constants.camera_state.get(raw, "Off") return self.make_response(res, raw_data=raw) class Snapshot(BaseResource): """Manage the acquisition of an image using the sequencer.""" Loading @@ -221,6 +243,7 @@ class Snapshot(BaseResource): res = await self.run_blocking(seq.tpl.abort) return self.make_response(res) class SnapshotRecenter(BaseResource): """Manage the recentering via the the apply_offset function""" Loading Loading @@ -255,6 +278,7 @@ class SnapshotRecenter(BaseResource): seq.tpl.recenter = False return self.make_response(False) class SnapshotDomeslewing(BaseResource): """Get dome slewing status""" Loading Loading @@ -288,6 +312,7 @@ class Connection(BaseResource): res = await self.run_blocking(lambda: self.dev.connection) return self.make_response(res) class Settings(BaseResource): '''General camera settings.''' Loading