Loading noctua/api/__init__.py +3 −2 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ from quart import Blueprint, request, abort from noctua import devices from noctua.api.sequencer_instance import seq from noctua.api.basecontext import ends, resource_registry, config_path from noctua.utils.logger import log from .blocks import blocks_api from .defaults import defaults_api from .sequencer import sequencer_api Loading Loading @@ -58,10 +59,10 @@ def dynamic_import(url_path): # Improved debug print with methods list full_resource_path = f"{module.__name__}.{resource_class.__name__}" print(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: print(f"Error loading {url_path}: {e}") log.warning(f"Error loading route: {url_path<40} {e}") # Load all routes from ini for section in ends.sections(): Loading noctua/api/webcam.py +60 −17 Original line number Diff line number Diff line #!/usr/bin/env python3 # -*- coding: utf-8 -*- '''REST API for IP camera related operations''' # System modules import asyncio # Custom modules from .baseresource import BaseResource # Third-party modules from quart import Response, stream_with_context import httpx # Custom modules from noctua.api.baseresource import BaseResource class Pointing(BaseResource): """Position of the webcam.""" Loading @@ -26,21 +28,62 @@ class Pointing(BaseResource): res = await self.run_blocking(action) return self.make_response(res) class Snapshot(BaseResource): """Image from the webcam.""" async def get(self): """Retrieve a raw base/64 image from the webcam.""" """ Retrieve a raw JPEG image from the webcam. Returns ------- Response The raw JPEG binary data with appropriate headers. """ img_bytes = await self.run_blocking(lambda: self.dev.image) if not img_bytes: return Response("Camera unavailable", status=503) return Response(img_bytes, mimetype='image/jpeg', headers={'Cache-Control': 'no-store'}) class VideoStream(BaseResource): """Live MJPEG video stream proxy using Async HTTPX.""" async def get(self): """ Stream the MJPEG video directly to the client. Acts as an asynchronous reverse proxy. Returns ------- Response An asynchronous chunked streaming response. """ img = await self.run_blocking(lambda: self.dev.image) # Recupera l'URL e le credenziali dal Layer 1 senza chiamare request bloccanti cam_url = f"{self.dev.addr}/video/mjpg.cgi" # Original logic for status codes code = 200 if not img: code = 401 # Unauthorized/Not available elif getattr(self.dev, 'error', []): code = 501 # Not Implemented / Device Error # Usiamo stream_with_context per permettere a Quart di gestire la disconnessione del client @stream_with_context async def generate(): """ Asynchronous generator to yield video chunks. """ try: # Apre una connessione asincrona verso la DLink async with httpx.AsyncClient() as client: async with client.stream("GET", cam_url, timeout=10.0) as r: # Legge e inoltra il flusso pezzo per pezzo async for chunk in r.aiter_bytes(chunk_size=4096): yield chunk except Exception as e: # Se il browser chiude la pagina o la telecamera si scollega, chiudiamo lo stream pass # Decoding binary data to string for JSON transport response_data = img.decode("ISO-8859-1") if img else img return Response( generate(), mimetype='multipart/x-mixed-replace; boundary=--video boundary--' ) noctua/config/api.ini +13 −9 Original line number Diff line number Diff line Loading @@ -302,17 +302,21 @@ resource = Settings device = cam depends-on = /camera/power # ############## # # webcam # ############## ############## # webcam ############## [/webcam/snapshot] resource = Snapshot device = ipcam # [/webcam/snapshot] # resource = Snapshot # device = ipcam [/webcam/position] resource = Pointing device = ipcam # [/webcam/position] # resource = Pointing # device = ipcam [/webcam/stream] resource = VideoStream device = ipcam # ############## # # environment Loading noctua/config/devices.ini +6 −5 Original line number Diff line number Diff line Loading @@ -119,6 +119,12 @@ node = ASCOM_REMOTE outlet = 3 ###################################### [ipcam] module = ipcam class = Webcam node = IPCAM ###################################### # [tel_temp] Loading @@ -128,11 +134,6 @@ outlet = 3 # outlet1 = 1 # outlet2 = 2 # [ipcam] # module = ipcam # class = Webcam # node = IPCAM # [met] # module = meteo # class = Meteo Loading noctua/devices/ipcam.py +53 −9 Original line number Diff line number Diff line Loading @@ -25,6 +25,31 @@ class DlinkDCSCamera(BaseDevice): self.timeout = 3 self.error = [] def get_stream(self, method): """ Open a continuous HTTP stream to the camera. Parameters ---------- method : str The CGI endpoint (e.g., 'video/mjpg'). Returns ------- requests.Response The raw response object with stream=True. """ try: res = requests.get(f"{self.addr}/{method}.cgi", stream=True, timeout=5) res.raise_for_status() return res except requests.exceptions.RequestException as e: log.error(f"IPCAM Stream Error: {e}") self.error.append(str(e)) return None @check.request_errors def get(self, method, params=[], text=True): '''Send a HTTP GET request to the device address.''' Loading Loading @@ -112,19 +137,38 @@ class Webcam(DlinkDCSCamera): @property def image(self): '''Get IP Camera image in raw format.''' """ Get IP Camera image in raw JPEG format. res = self.get('image/jpeg', text=False) Returns ------- bytes or None The raw binary data of the JPEG image. """ return res return self.get('image/jpeg', text=False) @property def video(self): '''Get IP Camera video as mjpeg stream.''' def video_stream(self): """ Get the continuous MJPEG video stream object. Returns ------- requests.Response The streaming response. """ return self.get_stream('video/mjpg') # @property # def video(self): # '''Get IP Camera video as mjpeg stream.''' # res = self.get('video/mjpg', text=False) # return res res = self.get('video/mjpg', text=False) return res @property def save_image(self, filename="temp.jpeg"): Loading Loading
noctua/api/__init__.py +3 −2 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ from quart import Blueprint, request, abort from noctua import devices from noctua.api.sequencer_instance import seq from noctua.api.basecontext import ends, resource_registry, config_path from noctua.utils.logger import log from .blocks import blocks_api from .defaults import defaults_api from .sequencer import sequencer_api Loading Loading @@ -58,10 +59,10 @@ def dynamic_import(url_path): # Improved debug print with methods list full_resource_path = f"{module.__name__}.{resource_class.__name__}" print(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: print(f"Error loading {url_path}: {e}") log.warning(f"Error loading route: {url_path<40} {e}") # Load all routes from ini for section in ends.sections(): Loading
noctua/api/webcam.py +60 −17 Original line number Diff line number Diff line #!/usr/bin/env python3 # -*- coding: utf-8 -*- '''REST API for IP camera related operations''' # System modules import asyncio # Custom modules from .baseresource import BaseResource # Third-party modules from quart import Response, stream_with_context import httpx # Custom modules from noctua.api.baseresource import BaseResource class Pointing(BaseResource): """Position of the webcam.""" Loading @@ -26,21 +28,62 @@ class Pointing(BaseResource): res = await self.run_blocking(action) return self.make_response(res) class Snapshot(BaseResource): """Image from the webcam.""" async def get(self): """Retrieve a raw base/64 image from the webcam.""" """ Retrieve a raw JPEG image from the webcam. Returns ------- Response The raw JPEG binary data with appropriate headers. """ img_bytes = await self.run_blocking(lambda: self.dev.image) if not img_bytes: return Response("Camera unavailable", status=503) return Response(img_bytes, mimetype='image/jpeg', headers={'Cache-Control': 'no-store'}) class VideoStream(BaseResource): """Live MJPEG video stream proxy using Async HTTPX.""" async def get(self): """ Stream the MJPEG video directly to the client. Acts as an asynchronous reverse proxy. Returns ------- Response An asynchronous chunked streaming response. """ img = await self.run_blocking(lambda: self.dev.image) # Recupera l'URL e le credenziali dal Layer 1 senza chiamare request bloccanti cam_url = f"{self.dev.addr}/video/mjpg.cgi" # Original logic for status codes code = 200 if not img: code = 401 # Unauthorized/Not available elif getattr(self.dev, 'error', []): code = 501 # Not Implemented / Device Error # Usiamo stream_with_context per permettere a Quart di gestire la disconnessione del client @stream_with_context async def generate(): """ Asynchronous generator to yield video chunks. """ try: # Apre una connessione asincrona verso la DLink async with httpx.AsyncClient() as client: async with client.stream("GET", cam_url, timeout=10.0) as r: # Legge e inoltra il flusso pezzo per pezzo async for chunk in r.aiter_bytes(chunk_size=4096): yield chunk except Exception as e: # Se il browser chiude la pagina o la telecamera si scollega, chiudiamo lo stream pass # Decoding binary data to string for JSON transport response_data = img.decode("ISO-8859-1") if img else img return Response( generate(), mimetype='multipart/x-mixed-replace; boundary=--video boundary--' )
noctua/config/api.ini +13 −9 Original line number Diff line number Diff line Loading @@ -302,17 +302,21 @@ resource = Settings device = cam depends-on = /camera/power # ############## # # webcam # ############## ############## # webcam ############## [/webcam/snapshot] resource = Snapshot device = ipcam # [/webcam/snapshot] # resource = Snapshot # device = ipcam [/webcam/position] resource = Pointing device = ipcam # [/webcam/position] # resource = Pointing # device = ipcam [/webcam/stream] resource = VideoStream device = ipcam # ############## # # environment Loading
noctua/config/devices.ini +6 −5 Original line number Diff line number Diff line Loading @@ -119,6 +119,12 @@ node = ASCOM_REMOTE outlet = 3 ###################################### [ipcam] module = ipcam class = Webcam node = IPCAM ###################################### # [tel_temp] Loading @@ -128,11 +134,6 @@ outlet = 3 # outlet1 = 1 # outlet2 = 2 # [ipcam] # module = ipcam # class = Webcam # node = IPCAM # [met] # module = meteo # class = Meteo Loading
noctua/devices/ipcam.py +53 −9 Original line number Diff line number Diff line Loading @@ -25,6 +25,31 @@ class DlinkDCSCamera(BaseDevice): self.timeout = 3 self.error = [] def get_stream(self, method): """ Open a continuous HTTP stream to the camera. Parameters ---------- method : str The CGI endpoint (e.g., 'video/mjpg'). Returns ------- requests.Response The raw response object with stream=True. """ try: res = requests.get(f"{self.addr}/{method}.cgi", stream=True, timeout=5) res.raise_for_status() return res except requests.exceptions.RequestException as e: log.error(f"IPCAM Stream Error: {e}") self.error.append(str(e)) return None @check.request_errors def get(self, method, params=[], text=True): '''Send a HTTP GET request to the device address.''' Loading Loading @@ -112,19 +137,38 @@ class Webcam(DlinkDCSCamera): @property def image(self): '''Get IP Camera image in raw format.''' """ Get IP Camera image in raw JPEG format. res = self.get('image/jpeg', text=False) Returns ------- bytes or None The raw binary data of the JPEG image. """ return res return self.get('image/jpeg', text=False) @property def video(self): '''Get IP Camera video as mjpeg stream.''' def video_stream(self): """ Get the continuous MJPEG video stream object. Returns ------- requests.Response The streaming response. """ return self.get_stream('video/mjpg') # @property # def video(self): # '''Get IP Camera video as mjpeg stream.''' # res = self.get('video/mjpg', text=False) # return res res = self.get('video/mjpg', text=False) return res @property def save_image(self, filename="temp.jpeg"): Loading