diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d1ac4b97..cddff59d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,3 @@ contact_links: - name: 📚 Documentation url: https://docs.dwe.ai/ about: View our Setup Documentation -- name: 💬 Community Forums - url: https://discuss.dwe.ai/ - about: Join our Forums to ask questions and get help! diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ffe01bec..d79a71ce 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,36 +1,21 @@ -# Description +## Summary -Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. +[Explain the *what* and *why* of this PR.] -Fixes #[insert issue number(s) here] +Fixes #[issue number(s) here] ## Type of change -Please select all that apply. +- [ ] Bug fix +- [ ] Feature / Refactor +- [ ] Breaking change +- [ ] Misc. -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] This change requires a documentation update -- [ ] Other: [describe here] +## Quick Checklist -# How Has This Been Tested? - -Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration - -- [ ] Make sure code runs/builds on Linux **[REQUIRED]** - - [ ] Code produces no console errors upon launching the software -- [ ] Other (add any other tests run) - -# Checklist: - -- [ ] My code follows the style guidelines of this project -- [ ] I have performed a self-review of my own code -- [ ] I have added 1 or more reviewers to my pull request - - [ ] Reviewer has run the above tests to verify -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings -- [ ] Myself or a reviewer has tested my code to prove my fix is effective or that my feature works -- [ ] New and existing unit tests pass locally with my changes (if applicable) -- [ ] Any dependent changes have been merged and published in downstream modules +- [ ] I have reviewed my own code. +- [ ] I have tested these changes locally. +- [ ] I have tested these changes on the following platforms (optional): + - [ ] SVC + - [ ] microSVC (optional) + - [ ] SVC Pro diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 00000000..1465655a --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,51 @@ +name: Backend CI + +on: + # Commented to avoid double runs + push: + branches: + - main + # - "feature/**" + # - "fix/**" + # - "bugfix/**" + # - "refactor/**" + # paths: + # - backend_py/** + # - ".github/workflows/backend.yml" + pull_request: + branches: ["main"] + +jobs: + build: + runs-on: ubuntu-22.04 + + defaults: + run: + working-directory: ./backend_py + + steps: + - uses: actions/checkout@v4 + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | # TODO: switch to using proper dev dependencies + sudo apt-get install build-essential libdbus-glib-1-dev libdbus-1-dev libpython3-dev -y # For dbus-python + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff bandit ty # dev dependencies + + - name: Ruff linting + run: ruff check --output-format=github . + + - name: Ruff formatting + run: ruff format --check . + + - name: Bandit security check (high severity) + run: bandit -r . -lll + + - name: Type checking (ty) + run: ty check diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 00000000..308b0a16 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,47 @@ +name: Frontend CI + +on: + # Commented to avoid double runs + push: + branches: + - main + # - "feature/**" + # - "fix/**" + # - "bugfix/**" + # - "refactor/**" + # paths: + # - frontend/** + # - ".github/workflows/frontend.yml" + pull_request: + branches: ["main"] + +jobs: + Frontend: + runs-on: ubuntu-22.04 + + defaults: + run: + working-directory: ./frontend + + steps: + - name: "Checkout code" + uses: actions/checkout@v6 + + - name: "Set up Node.js" + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: "./frontend/package-lock.json" + + - name: "Install frontend dependencies" + run: npm ci + + - name: "Run frontend linting" + run: npm run lint + + - name: "Security audit" + run: npm audit --audit-level=high + + - name: "Build frontend" + run: npm run build diff --git a/.github/workflows/main.yml b/.github/workflows/release.yml similarity index 100% rename from .github/workflows/main.yml rename to .github/workflows/release.yml diff --git a/backend_py/pyproject.toml b/backend_py/pyproject.toml new file mode 100644 index 00000000..8ae18b7a --- /dev/null +++ b/backend_py/pyproject.toml @@ -0,0 +1,27 @@ +[tool.ruff] +line-length = 88 +indent-width = 4 +exclude = ["v4l2.py"] # exclude v4l2.py, since it's included as a lib directly + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", + "ANN2", +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.ty.rules] diff --git a/backend_py/run.py b/backend_py/run.py index 464063ff..21cf50cf 100644 --- a/backend_py/run.py +++ b/backend_py/run.py @@ -1,30 +1,37 @@ -# run.py runs the backend, creating a async socketio server and a FastAPI web framework, then -# both are passed into a Server instance to handle logic, and a combination of the two is hosted -# as a uvicorn server, which handles traffic +# run.py runs the backend, creating a async socketio server and a FastAPI web +# framework, then +# both are passed into a Server instance to handle logic, and a combination of the +# two is hosted as a uvicorn server, which handles traffic + +import asyncio +import logging +from contextlib import asynccontextmanager -from src import Server, FeatureSupport import socketio from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -import asyncio -from contextlib import asynccontextmanager -import logging + +from src import FeatureSupport, Server # TODO: narrow ORIGINS = ["*"] # Use AsyncServer sio = socketio.AsyncServer( - cors_allowed_origins='*', async_mode="asgi", transports=["websocket"] + cors_allowed_origins="*", async_mode="asgi", transports=["websocket"] ) # Define events @asynccontextmanager -async def lifespan(app: FastAPI): - server.serve() +async def lifespan(app: FastAPI): # noqa: ANN201 + await server.serve() yield print("Shutting down server...") + try: + server.shutdown() + except Exception as e: + print(f"Error during shutdown: {e}") # FastAPI application @@ -44,7 +51,12 @@ async def lifespan(app: FastAPI): # Server instance # server = Server(FeatureSupport.none(), sio, app, settings_path='.') server = Server( - FeatureSupport(ttyd=True, wifi=True, serial=True), sio, app, settings_path=".", log_level=logging.DEBUG, is_dev_mode=True + FeatureSupport(ttyd=True, wifi=True, serial=True), + sio, + app, + settings_path=".", + log_level=logging.DEBUG, + is_dev_mode=True, ) # Combine FastAPI and Socket.IO ASGI apps @@ -54,9 +66,8 @@ async def lifespan(app: FastAPI): if __name__ == "__main__": import uvicorn - async def main(): - config = uvicorn.Config(app, host="0.0.0.0", - port=5000, log_level="warning") + async def main() -> None: + config = uvicorn.Config(app, host="0.0.0.0", port=5000, log_level="warning") server = uvicorn.Server(config) await server.serve() diff --git a/backend_py/src/__init__.py b/backend_py/src/__init__.py index fe0338a0..379db5f6 100644 --- a/backend_py/src/__init__.py +++ b/backend_py/src/__init__.py @@ -1 +1,3 @@ -from .server import * +from .server import FeatureSupport, Server + +__all__ = ["Server", "FeatureSupport"] diff --git a/backend_py/src/logging/__init__.py b/backend_py/src/logging/__init__.py index c76c85a6..3e7ccd57 100644 --- a/backend_py/src/logging/__init__.py +++ b/backend_py/src/logging/__init__.py @@ -1,2 +1,4 @@ -from .log_handler import * -from .log_schemas import * \ No newline at end of file +from .log_handler import LogHandler +from .log_schemas import LogSchema + +__all__ = ["LogHandler", "LogSchema"] diff --git a/backend_py/src/logging/log_handler.py b/backend_py/src/logging/log_handler.py index 73775537..d5c47230 100644 --- a/backend_py/src/logging/log_handler.py +++ b/backend_py/src/logging/log_handler.py @@ -1,27 +1,23 @@ import logging + import socketio -from typing import List + from .log_schemas import LogSchema -import datetime class LogHandler(logging.Handler): def __init__(self, sio: socketio.AsyncServer, level: int | str = 0) -> None: super().__init__(level) self.sio = sio - self.logs: List[LogSchema] = [] - self.to_emit: List[LogSchema] = [] - self.file_path = self._create_path() - - def _create_path(self): - datetime.datetime.now().strftime("%Y-%m-%d--%H-%M-%S.log") + self.logs: list[LogSchema] = [] + self.to_emit: list[LogSchema] = [] - def pop_logs(self): + def pop_logs(self) -> list[LogSchema]: logs = self.to_emit self.to_emit = [] return logs - def emit(self, record): + def emit(self, record) -> None: log = { "timestamp": record.asctime, "level": record.levelname, diff --git a/backend_py/src/logging/log_schemas.py b/backend_py/src/logging/log_schemas.py index 4fc87ce5..1e98a565 100644 --- a/backend_py/src/logging/log_schemas.py +++ b/backend_py/src/logging/log_schemas.py @@ -1,5 +1,6 @@ from pydantic import BaseModel + class LogSchema(BaseModel): timestamp: str level: str @@ -7,4 +8,4 @@ class LogSchema(BaseModel): filename: str lineno: int function: str - message: str \ No newline at end of file + message: str diff --git a/backend_py/src/routes/__init__.py b/backend_py/src/routes/__init__.py index 4c945a65..eac10d76 100644 --- a/backend_py/src/routes/__init__.py +++ b/backend_py/src/routes/__init__.py @@ -1,9 +1,19 @@ -from .cameras import * -from .lights import * -from .logs import * -from .preferences import * -from .wifi import * -from .system import * -from .wired import * -from .recordings import * -from .pwm import * +from .cameras import camera_router +from .lights import lights_router +from .logs import logs_router +from .network import network_router +from .preferences import preferences_router +from .pwm import pwm_router +from .recordings import recordings_router +from .system import system_router + +__all__ = [ + "camera_router", + "lights_router", + "logs_router", + "preferences_router", + "system_router", + "recordings_router", + "pwm_router", + "network_router", +] diff --git a/backend_py/src/routes/cameras.py b/backend_py/src/routes/cameras.py index 8d32a191..517e6ebf 100644 --- a/backend_py/src/routes/cameras.py +++ b/backend_py/src/routes/cameras.py @@ -2,31 +2,42 @@ camera.py API endpoints for camera device management and streaming config -Handles listing connected devices, updating stream settings (resolution / fps), setting UVC controls, and dealing with Leader/Follower for stereo cameras +Handles listing connected devices, updating stream settings (resolution / fps), +setting UVC controls, and dealing with Leader/Follower for stereo cameras """ -from fastapi import APIRouter, Depends, Request -from ..services import DeviceManager, StreamInfoModel, DeviceNicknameModel, UVCControlModel, DeviceDescriptorModel, DeviceLeaderModel -import logging +from typing import cast -from typing import List, cast +from fastapi import APIRouter, Request -from ..services.cameras.pydantic_schemas import StreamInfoModel, DeviceNicknameModel, UVCControlModel, DeviceLeaderModel, DeviceModel, AddFollowerPayload, SimpleRequestStatusModel +from ..schemas import SimpleRequestStatusModel +from ..services.cameras import DeviceManager from ..services.cameras.exceptions import DeviceNotFoundException -from ..services.cameras.pydantic_schemas import DeviceType +from ..services.cameras.pydantic_schemas import ( + AddFollowerPayload, + DeviceDescriptorModel, + DeviceModel, + DeviceNicknameModel, + DeviceType, + StreamInfoModel, + UVCControlModel, +) from ..services.cameras.shd import SHDDevice -camera_router = APIRouter(tags=['cameras']) +camera_router = APIRouter(tags=["cameras"]) -@camera_router.get('', summary='Get all devices') -def get_devices(request: Request) -> List[DeviceModel]: + +@camera_router.get("", summary="Get all devices") +def get_devices(request: Request) -> list[DeviceModel]: device_manager: DeviceManager = request.app.state.device_manager return device_manager.get_devices() -@camera_router.post('/configure_stream', summary='Configure a stream') -async def configure_stream(request: Request, stream_info: StreamInfoModel): +@camera_router.post("/configure_stream", summary="Configure a stream") +async def configure_stream( + request: Request, stream_info: StreamInfoModel +) -> SimpleRequestStatusModel: device_manager: DeviceManager = request.app.state.device_manager device_manager.configure_device_stream(stream_info) @@ -34,7 +45,7 @@ async def configure_stream(request: Request, stream_info: StreamInfoModel): for device in device_manager.devices: if device.bus_info == stream_info.bus_info: if device.device_type != DeviceType.STELLARHD_FOLLOWER: - return {} + return SimpleRequestStatusModel(success=False) break for device in device_manager.devices: if device.device_type == DeviceType.STELLARHD_LEADER: @@ -42,57 +53,74 @@ async def configure_stream(request: Request, stream_info: StreamInfoModel): if stream_info.bus_info in stellarhd_device.followers: stellarhd_device.start_stream() - return {} + return SimpleRequestStatusModel(success=True) -@camera_router.post('/set_nickname', summary='Set a device nickname') -def set_nickname(request: Request, device_nickname: DeviceNicknameModel): +@camera_router.post("/set_nickname", summary="Set a device nickname") +def set_nickname( + request: Request, device_nickname: DeviceNicknameModel +) -> SimpleRequestStatusModel: device_manager: DeviceManager = request.app.state.device_manager device_manager.set_device_nickname( - device_nickname.bus_info, device_nickname.nickname) + device_nickname.bus_info, device_nickname.nickname + ) - return {} + return SimpleRequestStatusModel(success=True) -@camera_router.post('/set_uvc_control', summary='Set a UVC control') -def set_uvc_control(request: Request, uvc_control: UVCControlModel): +@camera_router.post("/set_uvc_control", summary="Set a UVC control") +def set_uvc_control( + request: Request, uvc_control: UVCControlModel +) -> SimpleRequestStatusModel: device_manager: DeviceManager = request.app.state.device_manager device_manager.set_device_uvc_control( - uvc_control.bus_info, uvc_control.control_id, uvc_control.value) + uvc_control.bus_info, uvc_control.control_id, uvc_control.value + ) - return {} + return SimpleRequestStatusModel(success=True) -@camera_router.post('/add_follower', summary='Add a device as a follower to another device') -def add_follower(request: Request, payload: AddFollowerPayload) -> SimpleRequestStatusModel: +@camera_router.post( + "/add_follower", summary="Add a device as a follower to another device" +) +def add_follower( + request: Request, payload: AddFollowerPayload +) -> SimpleRequestStatusModel: device_manager: DeviceManager = request.app.state.device_manager success = device_manager.add_follower( - payload.leader_bus_info, payload.follower_bus_info) + payload.leader_bus_info, payload.follower_bus_info + ) return SimpleRequestStatusModel(success=success) -@camera_router.post('/remove_follower', summary='Add a device as a follower to another device') -def remove_follower(request: Request, payload: AddFollowerPayload) -> SimpleRequestStatusModel: +@camera_router.post( + "/remove_follower", summary="Add a device as a follower to another device" +) +def remove_follower( + request: Request, payload: AddFollowerPayload +) -> SimpleRequestStatusModel: device_manager: DeviceManager = request.app.state.device_manager success = device_manager.remove_follower( - payload.leader_bus_info, payload.follower_bus_info) + payload.leader_bus_info, payload.follower_bus_info + ) return SimpleRequestStatusModel(success=success) -@camera_router.post('/restart_stream', summary='Restart a stream') -def restart_stream(request: Request, device_descriptor: DeviceDescriptorModel): +@camera_router.post("/restart_stream", summary="Restart a stream") +def restart_stream( + request: Request, device_descriptor: DeviceDescriptorModel +) -> SimpleRequestStatusModel: device_manager: DeviceManager = request.app.state.device_manager # will raise DeviceNotFoundException which will be handled by server try: - dev = device_manager._find_device_with_bus_info( - device_descriptor.bus_info) + dev = device_manager._find_device_with_bus_info(device_descriptor.bus_info) except DeviceNotFoundException: return SimpleRequestStatusModel(success=False) dev.start_stream() diff --git a/backend_py/src/routes/lights.py b/backend_py/src/routes/lights.py index a8ff9492..335d8607 100644 --- a/backend_py/src/routes/lights.py +++ b/backend_py/src/routes/lights.py @@ -5,32 +5,37 @@ Handles listing connected lights, setting intensity, and disabling lights """ -from fastapi import APIRouter, Depends, Request -from typing import List -from ..services import LightManager, Light, DisableLightInfo, SetLightInfo +from fastapi import APIRouter, Request + +from ..schemas import SimpleRequestStatusModel +from ..services.lights import DisableLightInfo, Light, LightManager, SetLightInfo lights_router = APIRouter(tags=["lights"]) @lights_router.get("") -def get_lights(request: Request) -> List[Light]: +def get_lights(request: Request) -> list[Light]: light_manager: LightManager = request.app.state.light_manager return light_manager.get_lights() @lights_router.post("/set_intensity") -def set_intensity(request: Request, set_light_info: SetLightInfo): +def set_intensity( + request: Request, set_light_info: SetLightInfo +) -> SimpleRequestStatusModel: light_manager: LightManager = request.app.state.light_manager light_manager.set_intensity(set_light_info.index, set_light_info.intensity) - return {} + return SimpleRequestStatusModel(success=True) @lights_router.route("/disable_pin", methods=["POST"]) -def disable_light(request: Request, disable_light_info: DisableLightInfo): +def disable_light( + request: Request, disable_light_info: DisableLightInfo +) -> SimpleRequestStatusModel: light_manager: LightManager = request.app.state.light_manager light_manager.disable_light( disable_light_info.controller_index, disable_light_info.pin ) - return {} + return SimpleRequestStatusModel(success=True) diff --git a/backend_py/src/routes/logs.py b/backend_py/src/routes/logs.py index 8c6f04dc..66cf985b 100644 --- a/backend_py/src/routes/logs.py +++ b/backend_py/src/routes/logs.py @@ -1,12 +1,12 @@ -from fastapi import APIRouter, Depends, Request -from typing import List -from ..logging import LogSchema, LogHandler +from fastapi import APIRouter, Request -logs_router = APIRouter(tags=['logs']) +from ..logging import LogHandler, LogSchema +logs_router = APIRouter(tags=["logs"]) -@logs_router.get('') -def get_logs(request: Request) -> List[LogSchema]: + +@logs_router.get("") +def get_logs(request: Request) -> list[LogSchema]: log_handler: LogHandler = request.app.state.log_handler return log_handler.logs diff --git a/backend_py/src/routes/network.py b/backend_py/src/routes/network.py new file mode 100644 index 00000000..b579683a --- /dev/null +++ b/backend_py/src/routes/network.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Request + +from ..services.network import ( + ConnectionProfileModel, + IPV4Configuration, + NetworkWrapper, + WiredDeviceModel, +) + +network_router = APIRouter(tags=["network"]) + + +# Ethernet +@network_router.get("/wired/devices", summary="Get the wired devices") +def get_wired_devices(request: Request) -> list[WiredDeviceModel]: + network_manager: NetworkWrapper = request.app.state.network_manager + return network_manager.get_wired_devices() + + +@network_router.get("/connection_profiles", summary="Get the connection profiles") +def get_connection_profiles(request: Request) -> list[ConnectionProfileModel]: + network_manager: NetworkWrapper = request.app.state.network_manager + return network_manager.get_connection_profiles() + + +@network_router.post( + "/update_connection_profile", summary="Update the profile of a given nmconnection" +) +async def update_connection_profile( + request: Request, path: str, ip_configuration: IPV4Configuration +) -> dict: + # TODO: change return type to a proper schema + network_manager: NetworkWrapper = request.app.state.network_manager + return { + "status": await network_manager.update_connection_profile( + path, ip_configuration + ) + } + + +@network_router.post( + "/wired/activate_profile", summary="Activate a given profile for a device" +) +async def activate_profile(request: Request, interface: str, profile_path: str) -> dict: + network_manager: NetworkWrapper = request.app.state.network_manager + return {"status": await network_manager.activate_interface(interface, profile_path)} diff --git a/backend_py/src/routes/preferences.py b/backend_py/src/routes/preferences.py index dc2a52ea..1aab3d83 100644 --- a/backend_py/src/routes/preferences.py +++ b/backend_py/src/routes/preferences.py @@ -5,29 +5,33 @@ Handles getting and setting preferences """ -from fastapi import APIRouter, Depends, Request -from typing import Dict -from ..services import PreferencesManager, SavedPreferencesModel +from fastapi import APIRouter, Request -preferences_router = APIRouter(tags=['preferences']) +from ..schemas import SimpleRequestStatusModel +from ..services.preferences import PreferencesManager, SavedPreferencesModel +preferences_router = APIRouter(tags=["preferences"]) -@preferences_router.get('') + +@preferences_router.get("") def get_preferences(request: Request) -> SavedPreferencesModel: preferences_manager: PreferencesManager = request.app.state.preferences_manager return preferences_manager.serialize_preferences() -@preferences_router.post('/save_preferences') -def set_preferences(request: Request, preferences: SavedPreferencesModel): +@preferences_router.post("/save_preferences") +def set_preferences( + request: Request, preferences: SavedPreferencesModel +) -> SimpleRequestStatusModel: preferences_manager: PreferencesManager = request.app.state.preferences_manager preferences_manager.save(preferences) - return {} + return SimpleRequestStatusModel(success=True) -@preferences_router.get('/get_recommended_host') -def get_recommended_host(request: Request) -> Dict[str, str]: - return {'host': request.client.host} +@preferences_router.get("/get_recommended_host") +def get_recommended_host(request: Request) -> dict[str, str]: + # FIXME + return {"host": request.client.host if request.client else ""} diff --git a/backend_py/src/routes/pwm.py b/backend_py/src/routes/pwm.py index 8e511eae..8ef012ba 100644 --- a/backend_py/src/routes/pwm.py +++ b/backend_py/src/routes/pwm.py @@ -4,29 +4,30 @@ API endpoints for pwm config """ -from fastapi import APIRouter, Depends, Request -from typing import Dict -from ..services import DeviceManager, SavedPreferencesModel +from fastapi import APIRouter, Request -pwm_router = APIRouter(tags=['pwm']) +from ..services.cameras import SerialPWMController +pwm_router = APIRouter(tags=["pwm"]) -@pwm_router.get('/frequency') + +@pwm_router.get("/frequency") def get_frequency(request: Request) -> float: - device_manager: DeviceManager = request.app.state.device_manager + serial: SerialPWMController = request.app.state.serial - return device_manager.serial.frequency + # FIXME: all serial related items should be exclusively accessible via a wrapper + return serial.frequency -@pwm_router.post('/frequency') -def set_frequency(request: Request, frequency: float): - device_manager: DeviceManager = request.app.state.device_manager +@pwm_router.post("/frequency") +def set_frequency(request: Request, frequency: float) -> None: + serial: SerialPWMController = request.app.state.serial - device_manager.serial.apply(frequency, 30) + serial.apply(frequency, 30) -@pwm_router.post('/apply_from_fps') -def apply_from_fps(request: Request, fps: int): - device_manager: DeviceManager = request.app.state.device_manager +@pwm_router.post("/apply_from_fps") +def apply_from_fps(request: Request, fps: int) -> None: + serial: SerialPWMController = request.app.state.serial - device_manager.serial.apply_from_fps(fps) + serial.apply_from_fps(fps) diff --git a/backend_py/src/routes/recordings.py b/backend_py/src/routes/recordings.py index c91a04df..3bb29630 100644 --- a/backend_py/src/routes/recordings.py +++ b/backend_py/src/routes/recordings.py @@ -2,26 +2,27 @@ recording.py API endpoints for accessing video file library -Handles listing recording metadata, downloading / deleting / renaming recordings, and downloading all recordings as ZIP +Handles listing recording metadata, downloading / deleting / renaming recordings, +and downloading all recordings as ZIP """ -from fastapi import APIRouter, Depends, Request, HTTPException +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse -from typing import List -from ..services import RecordingsService, RecordingInfo -recordings_router = APIRouter(tags=['recordings']) +from ..services.recordings import RecordingInfo, RecordingsService +recordings_router = APIRouter(tags=["recordings"]) -@recordings_router.get('', summary='Get all recordings') -def get_recordings(request: Request) -> List[RecordingInfo]: + +@recordings_router.get("", summary="Get all recordings") +def get_recordings(request: Request) -> list[RecordingInfo]: recordings_service: RecordingsService = request.app.state.recordings_service return recordings_service.get_recordings() -@recordings_router.get('/{recording_path}', summary='Get a specific recording') -def get_recording(request: Request, recording_path: str): +@recordings_router.get("/{recording_path}", summary="Get a specific recording") +def get_recording(request: Request, recording_path: str) -> FileResponse: recordings_service: RecordingsService = request.app.state.recordings_service @@ -30,45 +31,54 @@ def get_recording(request: Request, recording_path: str): raise HTTPException(status_code=404, detail="Recording not found") headers = {} - if request.query_params.get('download', 'false').lower() == 'true': - headers['Content-Disposition'] = "attachment; filename=" + \ - recording_info.name + "." + recording_info.format + if request.query_params.get("download", "false").lower() == "true": + headers["Content-Disposition"] = ( + "attachment; filename=" + recording_info.name + "." + recording_info.format + ) return FileResponse(recording_info.path, headers=headers) -@recordings_router.delete('/{recording_path}', summary='Delete a recording') -def delete_recording(request: Request, recording_path: str): +@recordings_router.delete("/{recording_path}", summary="Delete a recording") +def delete_recording(request: Request, recording_path: str) -> list[RecordingInfo]: recordings_service: RecordingsService = request.app.state.recordings_service response = recordings_service.delete_recording(recording_path) - if response == False: + if not response: raise HTTPException( - status_code=404, detail="Recording not found or could not be deleted") + status_code=404, detail="Recording not found or could not be deleted" + ) return response -@recordings_router.patch('/{old_name}/{new_name}', summary='Rename a recording') -def rename_recording(request: Request, old_name: str, new_name: str): +@recordings_router.patch("/{old_name}/{new_name}", summary="Rename a recording") +def rename_recording( + request: Request, old_name: str, new_name: str +) -> list[RecordingInfo]: recordings_service: RecordingsService = request.app.state.recordings_service response = recordings_service.rename_recording(old_name, new_name) - if response == False: + if not response: raise HTTPException( - status_code=404, detail="Recording not found or could not be renamed") + status_code=404, detail="Recording not found or could not be renamed" + ) return response -@recordings_router.get('/zip', summary='Download all recordings as a zip file') -def zip_recordings(request: Request): +@recordings_router.get("/zip", summary="Download all recordings as a zip file") +def zip_recordings(request: Request) -> FileResponse: recordings_service: RecordingsService = request.app.state.recordings_service zip_file_path = recordings_service.zip_recordings() if not zip_file_path: raise HTTPException(status_code=404, detail="No recordings to zip") - resp = FileResponse(zip_file_path, media_type='application/zip', filename='recordings.zip', - headers={"Content-Disposition": "attachment; filename=recordings.zip"}) + resp = FileResponse( + zip_file_path, + media_type="application/zip", + filename="recordings.zip", + headers={"Content-Disposition": "attachment; filename=recordings.zip"}, + ) return resp diff --git a/backend_py/src/routes/system.py b/backend_py/src/routes/system.py index 086428ef..b727ecbb 100644 --- a/backend_py/src/routes/system.py +++ b/backend_py/src/routes/system.py @@ -6,20 +6,22 @@ """ from fastapi import APIRouter, Request -from ..services import SystemManager + +from ..schemas import SimpleRequestStatusModel +from ..services.system import SystemManager system_router = APIRouter(tags=["system"]) @system_router.post("/restart", summary="Restart the system") -def restart(request: Request): +def restart(request: Request) -> SimpleRequestStatusModel: system_manager: SystemManager = request.app.state.system_manager system_manager.restart_system() - return {} + return SimpleRequestStatusModel(success=True) @system_router.post("/shutdown", summary="Shutdown the system") -def shutdown(request: Request): +def shutdown(request: Request) -> SimpleRequestStatusModel: system_manager: SystemManager = request.app.state.system_manager system_manager.shutdown_system() - return {} + return SimpleRequestStatusModel(success=True) diff --git a/backend_py/src/routes/wifi.py b/backend_py/src/routes/wifi.py deleted file mode 100644 index c630bbbe..00000000 --- a/backend_py/src/routes/wifi.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -wifi.py - -API endpoints for wifi network management -Handles listing available networks, connecting / disconnecting from networks, managing saved networks, and toggling wifi -""" - -from fastapi import APIRouter, Request -from typing import List -from ..services import ( - AsyncNetworkManager, - NetworkConfig, - Status, - AccessPoint, - Connection, - ConnectionResultModel -) - -wifi_router = APIRouter(tags=["wifi"]) - - -@wifi_router.get("/status", summary="Get the WiFi Status") -def wifi_status(request: Request) -> Status: - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - active_connection = wifi_manager.get_status() - return active_connection - - -@wifi_router.get("/access_points", summary="Get the scanned access points") -def access_points(request: Request) -> List[AccessPoint]: - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - aps = wifi_manager.get_access_points() - - ap_list = [] - for ap in aps: - try: - requires_password = wifi_manager._requires_password(ap) - ap_list.append({ - "ssid": ap.ssid, - "strength": ap.strength, - "requires_password": requires_password - }) - except Exception as e: - # Network is no longer available, ignore it - continue - return ap_list - - -@wifi_router.get("/connections", summary="Get the known WiFi connections list") -def list_wifi_connections(request: Request) -> List[Connection]: - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - return wifi_manager.list_connections() - - -@wifi_router.post("/connect", summary="Connect to a network") -async def connect(request: Request, network_config: NetworkConfig) -> ConnectionResultModel: - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - result = await wifi_manager.connect(network_config.ssid, network_config.password) - - return ConnectionResultModel(result=result) - - -@wifi_router.post("/disconnect", summary="Disconnect from the connected network") -async def disconnect(request: Request): - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - return {"status": await wifi_manager.disconnect()} - - -@wifi_router.post("/forget", summary="Forget a network") -async def forget(request: Request, network_config: NetworkConfig): - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - return {"status": await wifi_manager.forget(network_config.ssid)} - - -@wifi_router.post("/off", summary="Turn off WiFi") -async def wifi_off(request: Request): - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - return {"status": await wifi_manager.turn_off_wifi()} - - -@wifi_router.post("/on", summary="Turn on WiFi") -async def wifi_on(request: Request): - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - return {"status": await wifi_manager.turn_on_wifi()} diff --git a/backend_py/src/routes/wired.py b/backend_py/src/routes/wired.py deleted file mode 100644 index 84951334..00000000 --- a/backend_py/src/routes/wired.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -wired.py - -API endpoints for connected ethernet networks -Handles managing dyanmic / static addresses for ethernet, managing priority (prioritize wifi or ethernet) -""" - -from fastapi import APIRouter, Depends, Request -from typing import List -from ..services import ( - AsyncNetworkManager, - IPConfiguration, - NetworkPriorityInformation, -) - -wired_router = APIRouter(tags=["wired"]) - - -# Ethernet -@wired_router.get( - "/get_ip_configuration", summary="Get the ethernet IP configuration" -) -def get_ip_configuration(request: Request) -> IPConfiguration | None: - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - - return wifi_manager.get_ip_configuration() - - -@wired_router.post( - "/set_ip_configuration", summary="Update the ethernet IP configuration" -) -async def set_static_ip(request: Request, ip_configuration: IPConfiguration): - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - return {"status": await wifi_manager.set_ip_configuration(ip_configuration)} - - -@wired_router.post("/set_network_priority", summary="Set the network priority") -async def set_network_priority( - request: Request, network_priority: NetworkPriorityInformation -): - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - await wifi_manager.set_network_priority(network_priority.network_priority) - return {} - - -@wired_router.get("/get_network_priority", summary="Get the network priority") -async def get_network_priority(request: Request) -> NetworkPriorityInformation: - wifi_manager: AsyncNetworkManager = request.app.state.wifi_manager - network_priority = NetworkPriorityInformation( - network_priority=wifi_manager.get_network_priority() - ) - return network_priority diff --git a/backend_py/src/schemas.py b/backend_py/src/schemas.py index 97a023cf..f5c7c373 100644 --- a/backend_py/src/schemas.py +++ b/backend_py/src/schemas.py @@ -7,9 +7,13 @@ class FeatureSupport(BaseModel): serial: bool @classmethod - def all(cls) -> 'FeatureSupport': + def all(cls) -> "FeatureSupport": return cls(ttyd=True, wifi=True, serial=True) @classmethod - def none(cls) -> 'FeatureSupport': + def none(cls) -> "FeatureSupport": return cls(ttyd=False, wifi=False, serial=False) + + +class SimpleRequestStatusModel(BaseModel): + success: bool = True diff --git a/backend_py/src/server.py b/backend_py/src/server.py index 9aff9b12..fc85f9be 100644 --- a/backend_py/src/server.py +++ b/backend_py/src/server.py @@ -2,23 +2,36 @@ server.py Handles server logic and initializes all the managers (settings, devices, lights, etc) -Starts device monitoring, wifi scan, and starts ttyd (teletypewriter daemon) to run in the background +Starts device monitoring, wifi scan, and starts ttyd (teletypewriter daemon) to run in +the background """ +import asyncio +import logging import logging.handlers -from fastapi.staticfiles import StaticFiles +import socketio +from fastapi import FastAPI -from .services import * # type: ignore -from .routes import * from .logging import LogHandler +from .routes import ( + camera_router, + lights_router, + logs_router, + network_router, + preferences_router, + pwm_router, + recordings_router, + system_router, +) from .schemas import FeatureSupport - -from fastapi import FastAPI -import socketio - -import logging -import datetime +from .services.cameras import DeviceManager, SerialPWMController, SettingsManager +from .services.lights import LightManager, create_pwm_controllers +from .services.network import NetworkWrapper +from .services.preferences import PreferencesManager +from .services.recordings import RecordingsService +from .services.system import SystemManager +from .services.ttyd import TTYDManager class Server: @@ -33,7 +46,7 @@ def __init__( app: FastAPI, settings_path: str = "/", log_level=logging.INFO, - is_dev_mode=False + is_dev_mode=False, ) -> None: # initialize the app self.app = app @@ -50,7 +63,8 @@ def __init__( self.root_logger.addHandler(self.stream_handler) self.log_handler = LogHandler(self.sio) self.log_formatter = logging.Formatter( - "%(asctime)s - %(levelname)s - [%(name)s] - %(filename)s:%(lineno)d - %(funcName)s() - %(message)s" + "%(asctime)s - %(levelname)s - [%(name)s] - %(filename)s:%(lineno)d - " + "%(funcName)s() - %(message)s" ) self.stream_handler.setFormatter(self.log_formatter) self.file_handler = logging.handlers.RotatingFileHandler( @@ -68,9 +82,14 @@ def __init__( self.settings_manager = SettingsManager(settings_path) self.preferences_manager = PreferencesManager(settings_path) + # Serial + self.serial = SerialPWMController( + frequency_offset=self.preferences_manager.get_preferences().frequency_offset + ) + # Device Manager self.device_manager = DeviceManager( - settings_manager=self.settings_manager, sio=self.sio, use_serial=self.feature_support.serial, preferences=self.preferences_manager.get_preferences() + settings_manager=self.settings_manager, sio=self.sio, serial=self.serial ) # Lights @@ -78,41 +97,12 @@ def __init__( self.server_logger = logging.getLogger("dwe_os_2.Server") - # Wifi support - if self.feature_support.wifi: - try: - self.wifi_manager = AsyncNetworkManager() - self.app.include_router(wifi_router, prefix="/api/wifi") - self.app.include_router(wired_router, prefix="/api/wired") - self.wifi_manager.on( - "ip_changed", - lambda: asyncio.create_task(self.sio.emit("ip_changed")), - ) - self.wifi_manager.on( - "aps_changed", - lambda: asyncio.create_task(self.sio.emit("aps_changed")), - ) - self.wifi_manager.on( - "connections_changed", - lambda: asyncio.create_task( - self.sio.emit("connections_changed")), - ) - self.wifi_manager.on( - "connection_changed", - lambda: asyncio.create_task( - self.sio.emit("connection_changed")), - ) - self.wifi_manager.on( - "disconnected", - lambda: asyncio.create_task( - self.sio.emit("wifi_disconnected")), - ) - - except Exception as e: - self.server_logger.warning( - f"Error occurred while initializing WiFi: {e} so WiFi will not be supported" - ) - self.feature_support.wifi = False + self.network_wrapper = NetworkWrapper(sio) + + self.network_wrapper.on( + "refresh_ui", + lambda: asyncio.create_task(self.sio.emit("refresh_wired_config")), + ) self.system_manager = SystemManager() @@ -122,6 +112,8 @@ def __init__( if self.feature_support.ttyd: self.ttyd_manager = TTYDManager(is_dev_mode) + # TODO: https://fastapi.tiangolo.com/tutorial/dependencies/ + # FAST API self.app.state.device_manager = self.device_manager self.app.state.log_handler = self.log_handler @@ -132,10 +124,9 @@ def __init__( self.app.state.ttyd_manager = ( self.ttyd_manager if self.feature_support.ttyd else None ) - self.app.state.wifi_manager = ( - self.wifi_manager if self.feature_support.wifi else None - ) + self.app.state.network_manager = self.network_wrapper self.app.state.recordings_service = self.recordings_service + self.app.state.serial = self.serial self.app.include_router(camera_router, prefix="/api/devices") self.app.include_router(preferences_router, prefix="/api/preferences") @@ -143,11 +134,16 @@ def __init__( self.app.include_router(lights_router, prefix="/api/lights") self.app.include_router(logs_router, prefix="/api/logs") self.app.include_router(recordings_router, prefix="/api/recordings") + self.app.include_router(network_router, prefix="/api/network") if feature_support.serial: self.app.include_router(pwm_router, prefix="/api/pwm") self.preferences_manager.on( - "preferences_updated", lambda preferences: self.device_manager.serial.set_frequency_offset(preferences.frequency_offset)) # type: ignore + "preferences_updated", + lambda preferences: self.serial.set_frequency_offset( + preferences.frequency_offset + ), + ) self.app.add_api_route( "/api/features", @@ -161,37 +157,35 @@ def __init__( # Error handling # TODO - async def emit_logs(self): + async def emit_logs(self) -> None: while True: logs = self.log_handler.pop_logs() for log in logs: await self.sio.emit("log", log.model_dump()) await asyncio.sleep(0.1) - def serve(self): + async def serve(self) -> None: # loop over and emit the logs to the client asyncio.create_task(self.emit_logs()) - if self.feature_support.serial and self.device_manager.serial: - self.device_manager.serial.start() + if self.feature_support.serial: + self.serial.start() self.device_manager.start_monitoring() - if self.feature_support.wifi: - self.wifi_manager.start_scanning() + + await self.network_wrapper.initialize() + if self.feature_support.ttyd: self.ttyd_manager.start() else: self.server_logger.info("Running without TTYD") - def shutdown(self): + def shutdown(self) -> None: self.server_logger.info("Shutting down") self.light_manager.cleanup() self.device_manager.stop_monitoring() + self.settings_manager.cleanup() if self.feature_support.ttyd: self.ttyd_manager.kill() - - # FIXME - # if self.feature_support.wifi: - # self.wifi_manager.stop_scanning() diff --git a/backend_py/src/services/__init__.py b/backend_py/src/services/__init__.py index 6b5d1e9a..cd3b7ec5 100644 --- a/backend_py/src/services/__init__.py +++ b/backend_py/src/services/__init__.py @@ -1,7 +1,11 @@ -from .cameras import * -from .lights import * -from .preferences import * -from .wifi import * -from .system import * -from .ttyd import * -from .recordings import * \ No newline at end of file +from . import cameras, lights, network, preferences, recordings, system, ttyd + +__all__ = [ + "cameras", + "lights", + "preferences", + "network", + "system", + "ttyd", + "recordings", +] diff --git a/backend_py/src/services/cameras/__init__.py b/backend_py/src/services/cameras/__init__.py index 75bb3c88..eae9b8df 100644 --- a/backend_py/src/services/cameras/__init__.py +++ b/backend_py/src/services/cameras/__init__.py @@ -1,11 +1,12 @@ -from .device_manager import * -from .device_utils import * -from .device import * -from .xu_controls import * -from .ehd import * -from .enumeration import * -from .pydantic_schemas import * -from .settings import * -from .shd import * -from .stream_runner import * -from .exceptions import * +from .device_manager import DeviceManager +from .device_utils import find_device_with_bus_info, list_diff +from .pwm import SerialPWMController +from .settings import SettingsManager + +__all__ = [ + "DeviceManager", + "find_device_with_bus_info", + "list_diff", + "SettingsManager", + "SerialPWMController", +] diff --git a/backend_py/src/services/cameras/camera_helper/camera_helper_loader.py b/backend_py/src/services/cameras/camera_helper/camera_helper_loader.py index cfc6c089..89973563 100644 --- a/backend_py/src/services/cameras/camera_helper/camera_helper_loader.py +++ b/backend_py/src/services/cameras/camera_helper/camera_helper_loader.py @@ -1,11 +1,11 @@ -from ctypes import CDLL import os import subprocess +from ctypes import CDLL dir_path = os.path.dirname(os.path.realpath(__file__)) -CAMERA_HELPER_SO_FILE = f'{dir_path}/build/camera_helper.so' +CAMERA_HELPER_SO_FILE = f"{dir_path}/build/camera_helper.so" if not os.path.exists(CAMERA_HELPER_SO_FILE): - subprocess.call(['sh', 'build.sh'], cwd=f'{dir_path}') + subprocess.call(["sh", "build.sh"], cwd=f"{dir_path}") camera_helper = CDLL(CAMERA_HELPER_SO_FILE) diff --git a/backend_py/src/services/cameras/device.py b/backend_py/src/services/cameras/device.py index 317884f4..7c417804 100644 --- a/backend_py/src/services/cameras/device.py +++ b/backend_py/src/services/cameras/device.py @@ -2,31 +2,42 @@ device.py Base class for camera device management -Handles v4l2 device finding, uvc controls, stream configuration, and device settings management +Handles v4l2 device finding, uvc controls, stream configuration, +and device settings management """ -from ctypes import * +import contextlib +import fcntl +import logging import struct -from dataclasses import dataclass -from typing import Dict, Callable, Any, Tuple from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Any import event_emitter as events - from linuxpy.video import device +from pydantic.v1 import NoneBytes from . import v4l2 from . import xu_controls as xu - -from .stream_utils import fourcc2s -from .enumeration import * -from .camera_helper.camera_helper_loader import * +from .camera_helper.camera_helper_loader import camera_helper +from .enumeration import DeviceInfo +from .pydantic_schemas import ( + ControlFlagsModel, + ControlModel, + ControlTypeEnum, + DeviceType, + FormatSizeModel, + IntervalModel, + MenuItemModel, + StreamEncodeTypeEnum, + StreamEndpointModel, + StreamTypeEnum, + V4LControlTypeEnum, +) +from .saved_pydantic_schemas import SavedDeviceModel from .stream_runner import Stream, StreamRunner -from .stream_utils import string_to_stream_encode_type -from .pydantic_schemas import * -from .saved_pydantic_schemas import * - -import logging +from .stream_utils import fourcc2s, string_to_stream_encode_type PID_VIDS = { "exploreHD": {"VID": 0xC45, "PID": 0x6366, "device_type": DeviceType.EXPLOREHD}, @@ -40,24 +51,75 @@ "PID": 0x6368, "device_type": DeviceType.STELLARHD_FOLLOWER, }, - "stellarHDPro: Leader": { - "VID": 0xC45, - "PID": 0x6369, - "device_type": DeviceType.STELLARHD_LEADER_PRO, + "exploreHD ": {"VID": 0x3961, "PID": 0x2100, "device_type": DeviceType.EXPLOREHD}, + "exploreHD Heavy": { + "VID": 0x3961, + "PID": 0x2200, + "device_type": DeviceType.EXPLOREHD, }, - "stellarHDPro: Follower": { - "VID": 0xC45, - "PID": 0x6370, - "device_type": DeviceType.STELLARHD_FOLLOWER_PRO, + "exploreHD Heavy (AQ)": { + "VID": 0x3961, + "PID": 0x2210, + "device_type": DeviceType.EXPLOREHD, + }, + "stellarHD Elite (AQ-L)": { + "VID": 0x3961, + "PID": 0x1211, + "device_type": DeviceType.STELLARHD_LEADER, + }, + "stellarHD Elite (AQ-F)": { + "VID": 0x3961, + "PID": 0x1212, + "device_type": DeviceType.STELLARHD_FOLLOWER, + }, + "stellarHD Elite (L)": { + "VID": 0x3961, + "PID": 0x1201, + "device_type": DeviceType.STELLARHD_LEADER, + }, + "stellarHD Elite (F)": { + "VID": 0x3961, + "PID": 0x1202, + "device_type": DeviceType.STELLARHD_FOLLOWER, + }, + "stellarHD (AQ-L)": { + "VID": 0x3961, + "PID": 0x1111, + "device_type": DeviceType.STELLARHD_LEADER, + }, + "stellarHD (AQ-F)": { + "VID": 0x3961, + "PID": 0x1112, + "device_type": DeviceType.STELLARHD_FOLLOWER, + }, + "stellarHD (L)": { + "VID": 0x3961, + "PID": 0x1101, + "device_type": DeviceType.STELLARHD_LEADER, + }, + "stellarHD (F)": { + "VID": 0x3961, + "PID": 0x1102, + "device_type": DeviceType.STELLARHD_FOLLOWER, + }, + "explore3D (Left)": { + "VID": 0x3961, + "PID": 0x3112, + "device_type": DeviceType.STELLARHD_FOLLOWER, + }, + "explore3D (Right)": { + "VID": 0x3961, + "PID": 0x3111, + "device_type": DeviceType.STELLARHD_LEADER, }, } -def lookup_pid_vid(vid: int, pid: int) -> Tuple[str, DeviceType]: +def lookup_pid_vid(vid: int, pid: int) -> tuple[str, DeviceType] | tuple[None, None]: for name in PID_VIDS: dev = PID_VIDS[name] if dev["VID"] == vid and dev["PID"] == pid: - return (name, dev["device_type"]) + return (name, DeviceType(dev["device_type"])) return (None, None) @@ -68,34 +130,33 @@ class Camera: def __init__(self, path: str) -> None: self.path = path - self._file_object = open(path) + self._file_object = open(path) # noqa: SIM115 self._fd = self._file_object.fileno() # get the file descriptor self._get_formats() + def close(self) -> None: + self._file_object.close() + # uvc_set_ctrl function defined in uvc_functions.c - def uvc_set_ctrl( - self, unit: int, ctrl: int, data: bytes, size: int - ) -> int: + def uvc_set_ctrl(self, unit: int, ctrl: int, data: bytes, size: int) -> int: return camera_helper.uvc_set_ctrl(self._fd, unit, ctrl, data, size) # uvc_get_ctrl function defined in uvc_functions.c - def uvc_get_ctrl( - self, unit: int, ctrl: int, data: bytes, size: int - ) -> int: + def uvc_get_ctrl(self, unit: int, ctrl: int, data: bytes, size: int) -> int: return camera_helper.uvc_get_ctrl(self._fd, unit, ctrl, data, size) def has_format(self, pixformat: str) -> bool: - return pixformat in self.formats.keys() + return pixformat in self.formats - def _get_formats(self): - self.formats: Dict[str, List[FormatSizeModel]] = {} + def _get_formats(self) -> None: + self.formats: dict[str, list[FormatSizeModel]] = {} for i in range(1000): v4l2_fmt = v4l2.v4l2_fmtdesc() v4l2_fmt.index = i v4l2_fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE try: fcntl.ioctl(self._fd, v4l2.VIDIOC_ENUM_FMT, v4l2_fmt) - except: + except OSError: break format_sizes = [] @@ -105,7 +166,7 @@ def _get_formats(self): frmsize.pixel_format = v4l2_fmt.pixelformat try: fcntl.ioctl(self._fd, v4l2.VIDIOC_ENUM_FRAMESIZES, frmsize) - except: + except OSError: break if frmsize.type == v4l2.V4L2_FRMSIZE_TYPE_DISCRETE: format_size = FormatSizeModel( @@ -123,7 +184,7 @@ def _get_formats(self): fcntl.ioctl( self._fd, v4l2.VIDIOC_ENUM_FRAMEINTERVALS, frmival ) - except: + except OSError: # This is expected and/or possible break if frmival.type == v4l2.V4L2_FRMIVAL_TYPE_DISCRETE: format_size.intervals.append( @@ -137,7 +198,7 @@ def _get_formats(self): class BaseOption(ABC): - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name = name @abstractmethod @@ -145,7 +206,7 @@ def get_value(self) -> Any: pass @abstractmethod - def set_value(self, value): + def set_value(self, value) -> NoneBytes: pass @@ -183,7 +244,7 @@ def __init__( self._data = b"\x00" * size # get the control value(s) - def get_value_raw(self): + def get_value_raw(self) -> tuple[Any, ...] | Any: self._get_ctrl() values = self._unpack(self._fmt) self._clear() @@ -193,19 +254,19 @@ def get_value_raw(self): return values # set the control value - def set_value_raw(self, *arg: list): + def set_value_raw(self, *arg: list) -> None: self._pack(self._fmt, *arg) self._set_ctrl() self._clear() - def set_value(self, value): + def set_value(self, value) -> None: converted = self._conversion_func_set(value) - if type(converted) == list: + if isinstance(converted, list): self.set_value_raw(*converted) else: self.set_value_raw(converted) - def get_value(self): + def get_value(self) -> list | Any: return self._conversion_func_get(self.get_value_raw()) # pack data to internal buffer @@ -215,10 +276,10 @@ def _pack(self, fmt: str, *arg: list) -> None: self._data = data + bytearray(self._size - len(data)) # unpack data from internal buffer - def _unpack(self, fmt: str) -> list: + def _unpack(self, fmt: str) -> tuple[Any, ...]: return struct.unpack_from(fmt, self._data) - def _set_ctrl(self): + def _set_ctrl(self) -> None: data = bytearray(self._size) data[0] = xu.DWE_DEVICE_TAG data[1] = self._command.value @@ -232,7 +293,7 @@ def _set_ctrl(self): self._unit.value, self._ctrl.value, self._data, self._size ) - def _get_ctrl(self): + def _get_ctrl(self) -> None: data = bytearray(self._size) data[0] = xu.DWE_DEVICE_TAG data[1] = self._command.value @@ -246,15 +307,14 @@ def _get_ctrl(self): self._unit.value, self._ctrl.value, self._data, self._size ) - def _clear(self): + def _clear(self) -> None: self._data = b"\x00" * self._size class Device(events.EventEmitter): - def __init__(self, device_info: DeviceInfo) -> None: super().__init__() - self.cameras: List[Camera] = [] + self.cameras: list[Camera] = [] for device_path in device_info.device_paths: self.cameras.append(Camera(device_path)) @@ -274,17 +334,18 @@ def __init__(self, device_info: DeviceInfo) -> None: self.nickname = "" self.stream = Stream() - # each device has a streamrunner, but not all of them are used if they are a follower (shd) - self.stream_runner = StreamRunner( - self.stream) + # each device has a streamrunner, but not all of them are used if + # they are a follower (shd) + self.stream_runner = StreamRunner(self.stream) for camera in self.cameras: for encoding in camera.formats: encode_type = string_to_stream_encode_type(encoding) - if encode_type: + if encode_type != StreamEncodeTypeEnum.NONE: self.stream.encode_type = encode_type # The highest resolution is the default - # Most users will use this, however it is available to be changed in the frontend + # Most users will use this, however it is available to be changed + # in the frontend self.stream.width = camera.formats[encoding][0].width self.stream.height = camera.formats[encoding][0].height self.stream.interval.denominator = ( @@ -295,12 +356,11 @@ def __init__(self, device_info: DeviceInfo) -> None: ) break - self.v4l2_device = device.Device( - self.cameras[0].path) # for control purposes + self.v4l2_device = device.Device(self.cameras[0].path) # for control purposes self.v4l2_device.open() # This must be configured by the implementing class - self._options: Dict[str, BaseOption] = self._get_options() + self._options: dict[str, BaseOption] = self._get_options() # list the controls and store them self.controls = [] @@ -309,16 +369,25 @@ def __init__(self, device_info: DeviceInfo) -> None: self._get_controls() - def _on_stream_error(self, err: str): + def _on_stream_error(self, err: str) -> None: self.logger.error(err) # TODO - def _get_options(self) -> Dict[str, BaseOption]: + def _get_options(self) -> dict[str, BaseOption]: return {} - def _get_controls(self): - fd = self.cameras[0]._fd - self.controls: List[ControlModel] = [] + def _get_controls(self) -> None: + # fd = self.cameras[0]._fd + self.controls: list[ControlModel] = [] + + if not self.v4l2_device.controls: + # TODO: If this happens, should delete the device, instead of just + # potentially dying (will never happen anyway) + self.logger.error( + "v4l2_device.controls == None. Unable to get controls. " + "This might be fatal." + ) + return for ctrl in self.v4l2_device.controls.values(): internal_enum = V4LControlTypeEnum(ctrl.type) @@ -328,24 +397,20 @@ def _get_controls(self): min_value = 0 step = 0 - try: + # FIXME: Should not surpress, should instead log this and use it + + with contextlib.suppress(BaseException): max_value = ctrl.maximum - except: - pass - try: + with contextlib.suppress(BaseException): min_value = ctrl.minimum - except: - pass - try: + with contextlib.suppress(BaseException): step = ctrl.step - except: - pass default_value = ctrl._info.default_value - menu: List[MenuItemModel] = [] + menu: list[MenuItemModel] = [] match control_type: case ControlTypeEnum.MENU: for i in ctrl.data: @@ -379,11 +444,14 @@ def configure_stream( height: int, interval: IntervalModel, stream_type: StreamTypeEnum, - stream_endpoints: List[StreamEndpointModel] = [], - ): + stream_endpoints: list[StreamEndpointModel] | None = None, + ) -> None: + if stream_endpoints is None: + stream_endpoints = [] + self.logger.info(self._fmt_log("Configuring stream")) - camera: Camera = None + camera: Camera | None = None match encode_type: case StreamEncodeTypeEnum.H264: camera = self.find_camera_with_format("H264") @@ -396,7 +464,8 @@ def configure_stream( if not camera: self.logger.warning( - "Attempting to select incompatible encoding type. This is undefined behavior." + "Attempting to select incompatible encoding type. " + "This is undefined behavior." ) return @@ -418,8 +487,8 @@ def add_control_from_option( control_type: ControlTypeEnum, max_value: float = 0, min_value: float = 0, - step: float = 0 - ): + step: float = 0, + ) -> None: try: option = self._options[option_name] value = int(option.get_value()) @@ -434,35 +503,42 @@ def add_control_from_option( max_value=max_value, min_value=min_value, step=step, - control_type=control_type + control_type=control_type, ), ), ) self._id_counter += 1 except AttributeError: import traceback + traceback.print_exc() self.logger.error( f"Unknown attribute: {self.__class__.__name__}._options[{option_name}]" ) self.logger.error("Failed to add option to controls list.") - def start_stream(self): + def start_stream(self) -> None: self.stream.enabled = True self.stream_runner.start() - def stop_stream(self): + def stop_stream(self) -> None: self.stream.enabled = False self.stream_runner.stop() - def load_settings(self, saved_device: SavedDeviceModel): + def close(self) -> None: + """ + Cleanup resources of the device + """ + for camera in self.cameras: + camera.close() + self.v4l2_device.close() + + def load_settings(self, saved_device: SavedDeviceModel) -> None: self.logger.info(self._fmt_log("Loading device settings")) for control in saved_device.controls: - try: - self.set_pu(control.control_id, control.value) - except: - continue + # CHECK: There used to be a try catch here.. + self.set_pu(control.control_id, control.value) self.configure_stream( saved_device.stream.encode_type, @@ -477,15 +553,21 @@ def load_settings(self, saved_device: SavedDeviceModel): if self.stream.enabled: self.start_stream() - def unconfigure_stream(self): + def unconfigure_stream(self) -> None: self.stream_runner.stop() - self.logger.info(self._fmt_log(f"Stream stopped")) + self.logger.info(self._fmt_log("Stream stopped")) - def get_pu(self, control_id: int): + def get_pu(self, control_id: int) -> int | None: + if not self.v4l2_device.controls: + self.logger.error("v4l2_device.controls == None. Unable to get pu") + return None control = self.v4l2_device.controls[control_id] return control.value - def set_pu(self, control_id: int, value: int): + def set_pu(self, control_id: int, value: int | float) -> bool | None: + if not self.v4l2_device.controls: + self.logger.critical("v4l2_device.controls is None; unable to run set_pu") + return if control_id < 0: # DWE control @@ -503,7 +585,7 @@ def set_pu(self, control_id: int, value: int): try: control.value = value except (AttributeError, PermissionError) as e: - self.logger.debug(f"Error setting control value: {e.strerror}") + self.logger.debug(f"Error setting control value: {e}") return False for ctrl in self.controls: if ctrl.control_id == control_id: @@ -519,11 +601,10 @@ def get_option(self, opt: str) -> Any: return None # set an option - def set_option(self, opt: str, value: Any): + def set_option(self, opt: str, value: Any) -> None: # self.logger.debug(self._fmt_log(f"Setting option - {opt} to {value}")) if opt in self._options: - return self._options[opt].set_value(value) - return None + self._options[opt].set_value(value) def _fmt_log(self, message: str) -> str: return f"{self.bus_info} - {message}" diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index e3db4d19..98fa341c 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -2,34 +2,38 @@ device_manager.py Handles functionality of device and montiors for devices -When it finds a new device, it creates a new device object and updates the device list and that devices settings +When it finds a new device, it creates a new device object and updates the device list +and that devices settings When it sees a missing device, it removes that device ojbect from the device list Manages a devices streaming state as well as changes to device name Manages the leader follower connections """ -from typing import List, cast -import logging -import event_emitter as events import asyncio +import logging import traceback +from typing import Any, cast -from .pydantic_schemas import * -from .device import Device, lookup_pid_vid, DeviceInfo, DeviceType -from .settings import SettingsManager -from .enumeration import list_devices -from .device_utils import list_diff, find_device_with_bus_info -from .exceptions import DeviceNotFoundException - +import event_emitter as events import socketio +from .device import Device, DeviceInfo, DeviceType, lookup_pid_vid +from .device_utils import find_device_with_bus_info, list_diff from .ehd import EHDDevice -from .shd import SHDDevice +from .enumeration import list_devices +from .exceptions import DeviceNotFoundException from .pwm.serial_pwm_controller import SerialPWMController -from ..preferences import SavedPreferencesModel +from .pydantic_schemas import ( + DeviceModel, + StreamEncodeTypeEnum, + StreamInfoModel, + StreamTypeEnum, +) +from .settings import SettingsManager +from .shd import SHDDevice -def todict(obj, classkey=None): +def todict(obj, classkey=None) -> Any: if isinstance(obj, dict): data = {} for k, v in obj.items(): @@ -60,30 +64,30 @@ class DeviceManager(events.EventEmitter): """ def __init__( - self, sio: socketio.AsyncServer, preferences: SavedPreferencesModel, use_serial=False, settings_manager=SettingsManager() + self, + sio: socketio.AsyncServer, + settings_manager: SettingsManager, + serial: SerialPWMController, ) -> None: - self.devices: List[Device] = [] + self.devices: list[Device] = [] self.sio = sio self.settings_manager = settings_manager self._is_monitoring = False # List of devices with stream errors - self.stream_errors: List[str] = [] + self.stream_errors: list[str] = [] - self.serial = None - if use_serial: - self.serial = SerialPWMController( - frequency_offset=preferences.frequency_offset) + self.serial = serial self.logger = logging.getLogger("dwe_os_2.cameras.DeviceManager") - def start_monitoring(self): + def start_monitoring(self) -> None: """ Begin monitoring for devices in the background """ self._is_monitoring = True asyncio.create_task(self._monitor()) - def stop_monitoring(self): + def stop_monitoring(self) -> None: """ Stop monitoring for devices """ @@ -91,6 +95,7 @@ def stop_monitoring(self): for device in self.devices: device.stream_runner.stop() + device.close() if self.serial: self.serial.close() @@ -113,30 +118,30 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: # Not a DWE device return None - # we need to broadcast that there was a gst error so that the frontend knows there may be a kernel issue + # we need to broadcast that there was a gst error so that the frontend knows + # there may be a kernel issue device.stream_runner.on( - "stream_error", lambda _: self._append_stream_error( - DeviceModel.model_validate(device))) + "stream_error", + lambda _: self._append_stream_error(DeviceModel.model_validate(device)), + ) if self.serial: - device.on("pwm_frequency", - lambda fps: self.serial.apply_from_fps(fps)) # type: ignore + device.on("pwm_frequency", lambda fps: self.serial.apply_from_fps(fps)) return device - def _append_stream_error(self, device: DeviceModel): + def _append_stream_error(self, device: DeviceModel) -> None: """ Helper function to append a gst error """ device.stream.enabled = False self.stream_errors.append(device.bus_info) - def get_devices(self): + def get_devices(self) -> list[DeviceModel]: """ Compile and sort a list of devices for jsonifcation """ - device_list = [DeviceModel.model_validate( - device) for device in self.devices] + device_list = [DeviceModel.model_validate(device) for device in self.devices] return device_list def set_device_option( @@ -184,7 +189,7 @@ def set_device_nickname(self, bus_info: str, nickname: str) -> bool: """ device = self._find_device_with_bus_info(bus_info) - self.logger.info(f'Setting nickname of {bus_info} to {nickname}') + self.logger.info(f"Setting nickname of {bus_info} to {nickname}") device.nickname = nickname @@ -192,7 +197,7 @@ def set_device_nickname(self, bus_info: str, nickname: str) -> bool: return True def set_device_uvc_control( - self, bus_info: str, control_id: int, control_value: int + self, bus_info: str, control_id: int, control_value: int | float ) -> bool: """ Set a device UVC control @@ -204,16 +209,15 @@ def set_device_uvc_control( self.settings_manager.save_device(device) return True - def add_follower(self, leader_bus_info: str, follower_bus_info: str): - ''' + def add_follower(self, leader_bus_info: str, follower_bus_info: str) -> bool: + """ Add a follower to a leader - ''' + """ leader_device = self._find_device_with_bus_info(leader_bus_info) follower_device = self._find_device_with_bus_info(follower_bus_info) if follower_device.device_type != DeviceType.STELLARHD_FOLLOWER: - self.logger.warning( - 'Attempted to add follower of non-follower type') + self.logger.warning("Attempted to add follower of non-follower type") return False leader_device = cast(SHDDevice, leader_device) @@ -225,20 +229,19 @@ def add_follower(self, leader_bus_info: str, follower_bus_info: str): return True - def remove_follower(self, leader_bus_info: str, follower_bus_info: str): - ''' + def remove_follower(self, leader_bus_info: str, follower_bus_info: str) -> bool: + """ Remove a follower from a leader - ''' + """ leader_device = self._find_device_with_bus_info(leader_bus_info) leader_device = cast(SHDDevice, leader_device) try: - follower_device = self._find_device_with_bus_info( - follower_bus_info) + follower_device = self._find_device_with_bus_info(follower_bus_info) except DeviceNotFoundException: # THERE IS NO INHERENT TRUTH TO THE EXISTANCE OF THE FOLLOWER # Expected in the case of an unplugged follower leader_device.remove_manual(follower_bus_info) - return + return False # This is allowed # if leader_device.device_type != DeviceType.STELLARHD_LEADER: @@ -247,8 +250,7 @@ def remove_follower(self, leader_bus_info: str, follower_bus_info: str): # return False if follower_device.device_type != DeviceType.STELLARHD_FOLLOWER: - self.logger.warning( - 'Attempted to remove follower of non-follower type') + self.logger.warning("Attempted to remove follower of non-follower type") return False follower_device = cast(SHDDevice, follower_device) @@ -268,7 +270,7 @@ def _find_device_with_bus_info(self, bus_info: str) -> Device: raise DeviceNotFoundException(bus_info) return device - async def _get_devices(self, old_devices: List[DeviceInfo]): + async def _get_devices(self, old_devices: list[DeviceInfo]) -> list[DeviceInfo]: # enumerate the devices devices_info = list_devices() @@ -316,31 +318,41 @@ async def _get_devices(self, old_devices: List[DeviceInfo]): device.stream_runner.stop() # What to do when a device is unplugged - # Remove unplugged followers from leaders, and unplugged leaders as leaders - if device.device_type == DeviceType.STELLARHD_LEADER or device.device_type == DeviceType.STELLARHD_FOLLOWER: + # Remove unplugged followers from leaders, and unplugged leaders + # as leaders + if ( + device.device_type == DeviceType.STELLARHD_LEADER + or device.device_type == DeviceType.STELLARHD_FOLLOWER + ): leader_casted = cast(SHDDevice, device) for follower_bus_info in leader_casted.followers: # This can be optimized, but it truly does not matter follower = self._find_device_with_bus_info( - follower_bus_info) - # Remember, follower might not exist now - never inherent truth to its existance + follower_bus_info + ) + # Remember, follower might not exist now - never inherent + # truth to its existance if follower: follower_casted = cast(SHDDevice, follower) leader_casted.remove_follower(follower_casted) - self.settings_manager.save_device( - leader_casted) + self.settings_manager.save_device(leader_casted) if device.device_type == DeviceType.STELLARHD_FOLLOWER: follower_casted = cast(SHDDevice, device) if follower_casted.is_managed: # TODO: Fix this for device in self.devices: - if device.device_type == DeviceType.STELLARHD_LEADER or device.device_type == DeviceType.STELLARHD_FOLLOWER: + if ( + device.device_type == DeviceType.STELLARHD_LEADER + or device.device_type + == DeviceType.STELLARHD_FOLLOWER + ): leader_casted = cast(SHDDevice, device) - if follower_casted.bus_info in leader_casted.followers: - leader_casted.remove_follower( - follower_casted) - self.settings_manager.save_device( - leader_casted) + if ( + follower_casted.bus_info + in leader_casted.followers + ): + leader_casted.remove_follower(follower_casted) + self.settings_manager.save_device(leader_casted) self.devices.remove(device) self.logger.info(f"Device Removed: {device_info.bus_info}") @@ -348,12 +360,13 @@ async def _get_devices(self, old_devices: List[DeviceInfo]): await self.sio.emit("device_removed", device_info.bus_info) if device_added: - # FIXME: Issue where sometimes frontend updates too quickly before the changes have been made + # FIXME: Issue where sometimes frontend updates too quickly before the + # changes have been made await self.sio.emit("device_added") return devices_info - async def _monitor(self): + async def _monitor(self) -> None: """ Internal code to monitor devices for changes """ @@ -366,7 +379,7 @@ async def _monitor(self): # get the list of devices and update the internal array devices_info = await self._get_devices(devices_info) - async def _emit_stream_error(self, device: str, errors: list | str): + async def _emit_stream_error(self, device: str, errors: list | str) -> None: """ Emit a stream_error and make sure it is not due to the device being unplugged """ @@ -374,7 +387,9 @@ async def _emit_stream_error(self, device: str, errors: list | str): for dev_info in devices_info: if device == dev_info.bus_info: - await self.sio.emit("stream_error", {"errors": errors, "bus_info": device}) + await self.sio.emit( + "stream_error", {"errors": errors, "bus_info": device} + ) return self.logger.debug("stream_error ignored due to device unplugged") diff --git a/backend_py/src/services/cameras/device_utils.py b/backend_py/src/services/cameras/device_utils.py index f131a0fd..c72bbdfb 100644 --- a/backend_py/src/services/cameras/device_utils.py +++ b/backend_py/src/services/cameras/device_utils.py @@ -1,19 +1,21 @@ """ device_utils.py -Utility functions for device_manager.py, specifically for finding added devices / removed devices +Utility functions for device_manager.py, specifically for finding added devices / + removed devices """ -from typing import List from .device import Device -def find_device_with_bus_info(devices: List[Device], bus_info: str) -> Device | None: + +def find_device_with_bus_info(devices: list[Device], bus_info: str) -> Device | None: for device in devices: if device.bus_info == bus_info: return device return None -def list_diff(listA, listB): + +def list_diff(listA: list, listB: list) -> list: # find the difference between lists diff = [] for element in listA: diff --git a/backend_py/src/services/cameras/ehd.py b/backend_py/src/services/cameras/ehd.py index fde7b389..552b79d9 100644 --- a/backend_py/src/services/cameras/ehd.py +++ b/backend_py/src/services/cameras/ehd.py @@ -1,59 +1,81 @@ """ ehd.py -Adds additional features to exploreHD devices through extension units (xu) as per UVC protocol -Uses options functionality to set defaults, ranges, and specifies registers for where these features store data +Adds additional features to exploreHD devices through extension units (xu) +as per UVC protocol +Uses options functionality to set defaults, ranges, and specifies registers for where +these features store data """ -from typing import Dict +from typing import cast + +from . import xu_controls as xu +from .device import BaseOption, ControlTypeEnum, Device, Option from .enumeration import DeviceInfo -from .device import Device, Option, ControlTypeEnum from .pydantic_schemas import H264Mode -from . import xu_controls as xu + class EHDDevice(Device): - ''' + """ Class for exploreHD devices - ''' + """ def __init__(self, device_info: DeviceInfo) -> None: super().__init__(device_info) - self.add_control_from_option( - 'vbr', False, ControlTypeEnum.BOOLEAN - ) + self.add_control_from_option("vbr", False, ControlTypeEnum.BOOLEAN) + + self.add_control_from_option("gop", 29, ControlTypeEnum.INTEGER, 29, 0, 1) self.add_control_from_option( - 'gop', 29, ControlTypeEnum.INTEGER, 29, 0, 1 - ) - - self.add_control_from_option( - 'bitrate', 10, ControlTypeEnum.INTEGER, 15, 0.1, 0.1 + "bitrate", 10, ControlTypeEnum.INTEGER, 15, 0.1, 0.1 ) - def _get_options(self) -> Dict[str, Option]: + def _get_options(self) -> dict[str, BaseOption]: options = {} # UVC xu bitrate control # Standard integer options - options['bitrate'] = Option( - self.cameras[2], '>I', xu.Unit.USR_ID, xu.Selector.USR_H264_CTRL, xu.Command.H264_BITRATE_CTRL, 'Bitrate', - lambda bitrate: int(round(bitrate * 1000000)), # convert to bps from mpbs (round for float imprecision) - lambda bitrate: bitrate / 1000000 # convert to mpbs from bps + options["bitrate"] = Option( + self.cameras[2], + ">I", + xu.Unit.USR_ID, + xu.Selector.USR_H264_CTRL, + xu.Command.H264_BITRATE_CTRL, + "Bitrate", + lambda bitrate: int( + round(bitrate * 1000000) + ), # convert to bps from mpbs (round for float imprecision) + lambda bitrate: cast(int, bitrate) / 1000000.0, # convert to mpbs from bps ) # UVC xu gop control - options['gop'] = Option( - self.cameras[2], 'H', xu.Unit.USR_ID, xu.Selector.USR_H264_CTRL, xu.Command.GOP_CTRL, 'Group of Pictures') + options["gop"] = Option( + self.cameras[2], + "H", + xu.Unit.USR_ID, + xu.Selector.USR_H264_CTRL, + xu.Command.GOP_CTRL, + "Group of Pictures", + ) # UVC xu H264 mode control # We want the mode option to be true or false # true indicates variable bitrate and false indicates constant bitrate # Maybe rename mode to vbr etc. - options['vbr'] = Option( - self.cameras[2], 'B', xu.Unit.USR_ID, xu.Selector.USR_H264_CTRL, xu.Command.H264_MODE_CTRL, 'Variable Bitrate', - lambda mode : H264Mode.MODE_VARIABLE_BITRATE.value if mode else H264Mode.MODE_CONSTANT_BITRATE.value, - lambda mode_value : H264Mode(mode_value) == H264Mode.MODE_VARIABLE_BITRATE) + options["vbr"] = Option( + self.cameras[2], + "B", + xu.Unit.USR_ID, + xu.Selector.USR_H264_CTRL, + xu.Command.H264_MODE_CTRL, + "Variable Bitrate", + lambda mode: ( + H264Mode.MODE_VARIABLE_BITRATE.value + if mode + else H264Mode.MODE_CONSTANT_BITRATE.value + ), + lambda mode_value: H264Mode(mode_value) == H264Mode.MODE_VARIABLE_BITRATE, + ) return options - diff --git a/backend_py/src/services/cameras/enumeration.py b/backend_py/src/services/cameras/enumeration.py index a93c6e86..32983877 100644 --- a/backend_py/src/services/cameras/enumeration.py +++ b/backend_py/src/services/cameras/enumeration.py @@ -1,22 +1,22 @@ """ enumeration.py -Searches the system for cameras using video4linux, create a DeviceInfo based on the camera, and then maps that to the camera's bus_info, +Searches the system for cameras using video4linux, create a DeviceInfo based on the +camera, and then maps that to the camera's bus_info, and return a sorted list of device_infos """ -from dataclasses import dataclass -from . import v4l2 import fcntl import os +from dataclasses import dataclass + from natsort import natsorted -import logging -from typing import List, Dict + +from . import v4l2 @dataclass class DeviceInfo: - device_name: str bus_info: str device_paths: list[str] @@ -24,52 +24,66 @@ class DeviceInfo: pid: int -def _get_device_attr(device_path, attr): - file_object = open(device_path + '/' + attr) - return file_object.read().strip() +def _get_device_attr(device_path, attr) -> str: + with open(device_path + "/" + attr) as file_object: + return file_object.read().strip() -def _get_vid_pid(devname): +def _get_vid_pid(devname) -> tuple[int, int] | None: cam_name = devname - syspath = '/sys/class/video4linux/' + cam_name - link = os.readlink(syspath) + '../../../../' - device_path = os.path.abspath( - '/sys/class/video4linux/' + link) - return (int(_get_device_attr(device_path, 'idVendor'), base=16), int(_get_device_attr(device_path, 'idProduct'), base=16)) + syspath = "/sys/class/video4linux/" + cam_name + link = os.readlink(syspath) + "../../../../" + device_path = os.path.abspath("/sys/class/video4linux/" + link) + + id_vendor_str = _get_device_attr(device_path, "idVendor") + id_product_str = _get_device_attr(device_path, "idProduct") + + if not id_vendor_str or not id_product_str: + return None + + return ( + int(id_vendor_str, base=16), + int(id_product_str, base=16), + ) -def list_devices(): +def list_devices() -> list[DeviceInfo]: # traverse the directory that has the list of all devices - devnames: List[str] = [] + devnames: list[str] = [] try: - devnames = os.listdir('/sys/class/video4linux/') - except FileNotFoundError as e: + devnames = os.listdir("/sys/class/video4linux/") + except FileNotFoundError: return [] - devices_info: List[DeviceInfo] = [] - devices_map: Dict[str, DeviceInfo] = {} + devices_info: list[DeviceInfo] = [] + devices_map: dict[str, DeviceInfo] = {} for devname in devnames: - devpath = f'/dev/{devname}' + devpath = f"/dev/{devname}" try: - fd = open(devpath) - except: + with open(devpath) as fd: + cap = v4l2.v4l2_capability() + fcntl.ioctl(fd, v4l2.VIDIOC_QUERYCAP, cap) + fd.close() + bus_info: str = bytes.decode(cap.bus_info) + # Correct type of bus info + if bus_info.startswith("usb"): + if bus_info in devices_map: + devices_map[bus_info].device_paths.append(devpath) + else: + device_name = cap.card.decode() + try: + vid_pid = _get_vid_pid(devname) + if not vid_pid: + # Never happens, just added this for linting + continue + (vid, pid) = vid_pid + except OSError: + continue + devices_map[bus_info] = DeviceInfo( + device_name, bus_info, [devpath], vid, pid + ) + except OSError: # Device was not initialized yet, just wait a bit continue - cap = v4l2.v4l2_capability() - fcntl.ioctl(fd, v4l2.VIDIOC_QUERYCAP, cap) - fd.close() - bus_info: str = bytes.decode(cap.bus_info) - # Correct type of bus info - if bus_info.startswith('usb'): - if bus_info in devices_map: - devices_map[bus_info].device_paths.append(devpath) - else: - device_name = cap.card.decode() - try: - (vid, pid) = _get_vid_pid(devname) - except OSError: - continue - devices_map[bus_info] = DeviceInfo( - device_name, bus_info, [devpath], vid, pid) # flatten the dict for bus_info in devices_map: diff --git a/backend_py/src/services/cameras/exceptions.py b/backend_py/src/services/cameras/exceptions.py index 2ae30262..098346a7 100644 --- a/backend_py/src/services/cameras/exceptions.py +++ b/backend_py/src/services/cameras/exceptions.py @@ -1,5 +1,5 @@ class DeviceNotFoundException(Exception): - '''Device not found''' + """Device not found""" def __init__(self, bus_info, *args: object) -> None: super().__init__(f'Device not found: "{bus_info}"', *args) diff --git a/backend_py/src/services/cameras/pwm/__init__.py b/backend_py/src/services/cameras/pwm/__init__.py new file mode 100644 index 00000000..522b5bdf --- /dev/null +++ b/backend_py/src/services/cameras/pwm/__init__.py @@ -0,0 +1,3 @@ +from .serial_pwm_controller import SerialPWMController + +__all__ = ["SerialPWMController"] diff --git a/backend_py/src/services/cameras/pwm/serial_pwm_controller.py b/backend_py/src/services/cameras/pwm/serial_pwm_controller.py index 1e2239d2..ed6b326f 100644 --- a/backend_py/src/services/cameras/pwm/serial_pwm_controller.py +++ b/backend_py/src/services/cameras/pwm/serial_pwm_controller.py @@ -1,6 +1,7 @@ -import serial -import logging import asyncio +import logging + +import serial from serial.tools import list_ports DWE_PWM_USB_VID = 0x3961 @@ -9,13 +10,7 @@ LEG_PWM_USB_VID = 0x0403 LEG_PWM_USB_PID = 0x6001 -frequency_table = { - 60: 60.0, - 50: 50.0, - 40: 40.0, - 30: 30.0, - 15: 15.0 -} +frequency_table = {60: 60.0, 50: 50.0, 40: 40.0, 30: 30.0, 15: 15.0} MONITOR_INTERVAL_SEC = 0.75 @@ -30,13 +25,15 @@ def _find_pwm_device_path() -> str | None: for port in list_ports.comports(): if port.vid is None or port.pid is None: continue - if (port.vid == DWE_PWM_USB_VID and port.pid == DWE_PWM_USB_PID) or (port.vid == LEG_PWM_USB_VID and port.pid == LEG_PWM_USB_PID): + if (port.vid == DWE_PWM_USB_VID and port.pid == DWE_PWM_USB_PID) or ( + port.vid == LEG_PWM_USB_VID and port.pid == LEG_PWM_USB_PID + ): return port.device return None class SerialPWMController: - def __init__(self, baudrate: int = 9600, frequency_offset: float = 0): + def __init__(self, baudrate: int = 9600, frequency_offset: float = 0) -> None: self.found_port = False self.has_printed_error = False self.frequency_offset = frequency_offset @@ -49,7 +46,7 @@ def __init__(self, baudrate: int = 9600, frequency_offset: float = 0): self._monitor_task: asyncio.Task | None = None self._running = False - def set_frequency_offset(self, frequency_offset: float): + def set_frequency_offset(self, frequency_offset: float) -> None: self.frequency_offset = frequency_offset self.apply(self.frequency, self.duty_cycle) @@ -68,9 +65,12 @@ async def _monitor_loop(self) -> None: while self._running: path = _find_pwm_device_path() if path: - if self.found_port and self.serial is not None: - if self.serial.port != path or not self.serial.is_open: - self._disconnect_serial() + if ( + self.found_port + and self.serial is not None + and (self.serial.port != path or not self.serial.is_open) + ): + self._disconnect_serial() if not self.found_port: try: self.serial = serial.Serial( @@ -78,16 +78,12 @@ async def _monitor_loop(self) -> None: ) self.found_port = True self.has_printed_error = False - self.logger.info( - "PWM USB serial connected at %s", path - ) + self.logger.info("PWM USB serial connected at %s", path) # Perform initial apply self.apply(self.frequency, self.duty_cycle) except serial.SerialException as e: if not self.has_printed_error: - self.logger.error( - "PWM serial open failed: %s", e - ) + self.logger.error("PWM serial open failed: %s", e) self.has_printed_error = True self._disconnect_serial() else: @@ -102,20 +98,20 @@ async def _monitor_loop(self) -> None: finally: self._disconnect_serial() - def start(self): + def start(self) -> None: """Starts the background asyncio task to sync settings.""" if self._monitor_task is not None and not self._monitor_task.done(): return self._running = True self._monitor_task = asyncio.create_task(self._monitor_loop()) - def apply(self, frequency: float, duty_cycle: int): + def apply(self, frequency: float, duty_cycle: int) -> None: # Make sure that even if the serial pwm is not yet connected, it will # have the correct clock frequency self.frequency = frequency self.duty_cycle = duty_cycle if not self.found_port: - self.logger.info(f"No connected USB serial PWM controller") + self.logger.info("No connected USB serial PWM controller") return command = f"{frequency + self.frequency_offset},{duty_cycle}\n" self.logger.info(f"Sending command {command.strip()}") @@ -123,13 +119,13 @@ def apply(self, frequency: float, duty_cycle: int): if self.serial: self.serial.write(command.encode("utf-8")) - def apply_from_fps(self, fps: int): + def apply_from_fps(self, fps: int) -> None: self.apply(frequency_table[fps], 30) - def stop(self): + def stop(self) -> None: self.apply(0, 0) - def close(self): + def close(self) -> None: self._running = False if self._monitor_task is not None: self._monitor_task.cancel() diff --git a/backend_py/src/services/cameras/pydantic_schemas.py b/backend_py/src/services/cameras/pydantic_schemas.py index 91a00154..35e773d1 100644 --- a/backend_py/src/services/cameras/pydantic_schemas.py +++ b/backend_py/src/services/cameras/pydantic_schemas.py @@ -5,10 +5,10 @@ Includes schemas for streams, controls, device info, and API request/response strutures """ -from pydantic import BaseModel, Field -from typing import List, Dict, Optional from enum import Enum, IntEnum +from pydantic import BaseModel, Field + class V4LControlTypeEnum(IntEnum): INTEGER = 1 @@ -79,7 +79,7 @@ class Config: class FormatSizeModel(BaseModel): width: int height: int - intervals: List[IntervalModel] + intervals: list[IntervalModel] class Config: from_attributes = True @@ -87,7 +87,7 @@ class Config: class CameraModel(BaseModel): path: str - formats: Dict[str, List[FormatSizeModel]] + formats: dict[str, list[FormatSizeModel]] class Config: from_attributes = True @@ -107,7 +107,7 @@ class ControlFlagsModel(BaseModel): min_value: float step: float control_type: ControlTypeEnum = Field(...) - menu: List[MenuItemModel] = Field(default_factory=list) + menu: list[MenuItemModel] = Field(default_factory=list) class Config: from_attributes = True @@ -126,7 +126,7 @@ class Config: class DeviceInfoModel(BaseModel): device_name: str bus_info: str - device_paths: List[str] + device_paths: list[str] vid: int pid: int @@ -155,7 +155,7 @@ class StreamModel(BaseModel): device_path: str encode_type: StreamEncodeTypeEnum stream_type: StreamTypeEnum - endpoints: List[StreamEndpointModel] + endpoints: list[StreamEndpointModel] width: int height: int interval: IntervalModel @@ -167,27 +167,28 @@ class Config: class DeviceModel(BaseModel): # List of cameras, e.g. /dev/video0, /dev/video2 - cameras: Optional[List[CameraModel]] = None + cameras: list[CameraModel] | None = None # List of camera controls and their values, default values, etc. - controls: List[ControlModel] + controls: list[ControlModel] # Stores information about the stream stream: StreamModel # e.g. exploreHD - name: Optional[str] = None + name: str | None = None vid: int pid: int # usb-0000:00: ... to uniquely identify the port bus_info: str # DWE.ai - manufacturer: Optional[str] = None + manufacturer: str | None = None # device nickname nickname: str # initial information used to construct the object, redundant data... - device_info: Optional[DeviceInfoModel] = None + device_info: DeviceInfoModel | None = None # 0 (exploreHD), 1 (Leader), 2 (Follower) device_type: DeviceType - # Only required for stellarHD (remember, followers CAN be leaders in some circumstances) - followers: List[str] = [] + # Only required for stellarHD + # (remember, followers CAN be leaders in some circumstances) + followers: list[str] = [] # True if is a follower and stream is managed by the leader is_managed: bool = False @@ -213,7 +214,7 @@ class StreamInfoModel(BaseModel): stream_format: StreamFormatModel encode_type: StreamEncodeTypeEnum enabled: bool - endpoints: List[StreamEndpointModel] + endpoints: list[StreamEndpointModel] class Config: from_attributes = True @@ -238,7 +239,7 @@ class Config: class DeviceLeaderModel(BaseModel): follower: str - leader: Optional[str] = None + leader: str | None = None class Config: from_attributes = True @@ -251,7 +252,3 @@ class DeviceDescriptorModel(BaseModel): class AddFollowerPayload(BaseModel): leader_bus_info: str follower_bus_info: str - - -class SimpleRequestStatusModel(BaseModel): - success: bool = True diff --git a/backend_py/src/services/cameras/saved_pydantic_schemas.py b/backend_py/src/services/cameras/saved_pydantic_schemas.py index 3739c9ec..d24006f0 100644 --- a/backend_py/src/services/cameras/saved_pydantic_schemas.py +++ b/backend_py/src/services/cameras/saved_pydantic_schemas.py @@ -2,13 +2,19 @@ saved_pydantic_schemas.py Defines Pydantic models and Enums for persisting device settings and configs -Includes schemas for serializing device states (streams, controls, nicknames) to JSON, keeping setting across reboots +Includes schemas for serializing device states (streams, controls, nicknames) to JSON, +keeping setting across reboots """ from pydantic import BaseModel -from typing import List, Optional -from .pydantic_schemas import StreamEndpointModel, IntervalModel, DeviceType, StreamEncodeTypeEnum, StreamTypeEnum +from .pydantic_schemas import ( + DeviceType, + IntervalModel, + StreamEncodeTypeEnum, + StreamEndpointModel, + StreamTypeEnum, +) class SavedControlModel(BaseModel): @@ -23,7 +29,7 @@ class Config: class SavedStreamModel(BaseModel): encode_type: StreamEncodeTypeEnum stream_type: StreamTypeEnum - endpoints: List[StreamEndpointModel] + endpoints: list[StreamEndpointModel] width: int height: int interval: IntervalModel @@ -40,9 +46,9 @@ class SavedDeviceModel(BaseModel): pid: int nickname: str stream: SavedStreamModel - controls: List[SavedControlModel] + controls: list[SavedControlModel] device_type: DeviceType - followers: Optional[List[str]] = [] + followers: list[str] | None = [] class Config: from_attributes = True diff --git a/backend_py/src/services/cameras/settings.py b/backend_py/src/services/cameras/settings.py index 43287ec9..805939c5 100644 --- a/backend_py/src/services/cameras/settings.py +++ b/backend_py/src/services/cameras/settings.py @@ -2,63 +2,76 @@ settings.py Manages persisting camera settings and configs -Handles loading and saving device configs to JSON, keeping setting across reboots, and manages background sync of settings +Handles loading and saving device configs to JSON, keeping setting across reboots, +and manages background sync of settings """ -from typing import List, Dict, cast -import threading -import time import json import logging +import threading +import time +from typing import cast +from .device import Device +from .device_utils import find_device_with_bus_info from .pydantic_schemas import DeviceType from .saved_pydantic_schemas import SavedDeviceModel, SavedLeaderFollowerPairModel -from .device import Device from .shd import SHDDevice -from .device_utils import find_device_with_bus_info - class SettingsManager: - def __init__(self, settings_path: str = ".") -> None: path = f"{settings_path}/device_settings.json" try: - self.file_object = open(path, "r+") + self.file_object = open(path, "r+") # noqa: SIM115 except FileNotFoundError: open(path, "w").close() - self.file_object = open(path, "r+") - self.to_save: List[SavedDeviceModel] = [] + self.file_object = open(path, "r+") # noqa: SIM115 + self.to_save: list[SavedDeviceModel] = [] + self.is_running = True + + # TODO: Switch to asyncio or lock self.thread = threading.Thread(target=self._run_settings_sync) self.thread.start() - self.leader_follower_pairs: List[SavedLeaderFollowerPairModel] = [] + self.leader_follower_pairs: list[SavedLeaderFollowerPairModel] = [] self.logger = logging.getLogger("dwe_os_2.SettingsManager") try: - settings: list[Dict] = json.loads(self.file_object.read()) - self.settings: List[SavedDeviceModel] = [ + settings: list[dict] = json.loads(self.file_object.read()) + self.settings: list[SavedDeviceModel] = [ SavedDeviceModel.model_validate(saved_device) for saved_device in settings ] - self.saved_by_bus_info: Dict[str, SavedDeviceModel] = { + self.saved_by_bus_info: dict[str, SavedDeviceModel] = { dev.bus_info: dev for dev in self.settings } except json.JSONDecodeError: self.file_object.seek(0) self.file_object.write("[]") self.file_object.truncate() + self.saved_by_bus_info = {} self.settings = [] self.file_object.flush() - def load_device(self, device: Device, devices: List[Device]): + def cleanup(self) -> None: + if self.file_object: + self.file_object.close() + + self.is_running = False + self.thread.join(timeout=1) + + def load_device(self, device: Device, devices: list[Device]) -> None: for saved_device in self.settings: if saved_device.bus_info == device.bus_info: if device.device_type != saved_device.device_type: self.logger.info( - f"Device {device.bus_info} with device_type: {str(device.device_type)} plugged into port of saved device_type: {str(saved_device.device_type)}. Discarding stored data." + f"Device {device.bus_info} with device_type: " + f"{str(device.device_type)} plugged into port of saved " + f"device_type: {str(saved_device.device_type)}. " + "Discarding stored data." ) self.settings.remove(saved_device) return @@ -66,28 +79,27 @@ def load_device(self, device: Device, devices: List[Device]): device.load_settings(saved_device) # We plugged in a new leader - if isinstance(device, SHDDevice): + if isinstance(device, SHDDevice) and saved_device.followers: for follower_bus_info in saved_device.followers: - follower = find_device_with_bus_info( - devices, follower_bus_info) + follower = find_device_with_bus_info(devices, follower_bus_info) if not follower: self.logger.warning( - f"Follower device with bus_info {follower_bus_info} not currently connected" + f"Follower device with bus_info {follower_bus_info} " + "not currently connected" ) continue if follower.device_type != DeviceType.STELLARHD_FOLLOWER: self.logger.warning( - f"Follower device {follower.bus_info} is not of follower type, skipping" + f"Follower device {follower.bus_info} is not of " + "follower type, skipping" ) saved_device.followers.remove(follower_bus_info) continue follower = cast(SHDDevice, follower) - device = cast(SHDDevice, device) if follower.is_managed: - self.logger.info( - f"Saved follower already has a new leader") + self.logger.info("Saved follower already has a new leader") # This is true when the follower has now gotten a new leader saved_device.followers.remove(follower_bus_info) continue @@ -96,7 +108,7 @@ def load_device(self, device: Device, devices: List[Device]): # We plugged in a new follower if device.device_type == DeviceType.STELLARHD_FOLLOWER: for potential_leader in devices: - # Skip if the potential leader is not an SHDDevice (cannot lead) + # Skip if the potential leader is not an SHDDevice (cannot lead) if not isinstance(potential_leader, SHDDevice): continue @@ -106,33 +118,31 @@ def load_device(self, device: Device, devices: List[Device]): continue saved_leader = self.saved_by_bus_info.get( - potential_leader.bus_info) - if not saved_leader: + potential_leader.bus_info + ) + if not saved_leader or not saved_leader.followers: continue if device.bus_info in saved_leader.followers: follower = cast(SHDDevice, device) - leader = cast(SHDDevice, potential_leader) - leader.add_follower(follower) + potential_leader.add_follower(follower) break # Only follow one leader return - def link_followers(self, devices: List[Device]): - ''' + def link_followers(self, devices: list[Device]) -> None: + """ Run this when we need to check for new devices - ''' + """ for leader in devices: # Changed: We now allow followers to be leaders (of other followers) if not isinstance(leader, SHDDevice): continue - leader = cast(SHDDevice, leader) - saved = self.saved_by_bus_info.get(leader.bus_info) # This device has not been saved - if not saved: + if not saved or not saved.followers: continue for follower_bus_info in saved.followers: @@ -140,19 +150,19 @@ def link_followers(self, devices: List[Device]): # Already loaded continue - follower = find_device_with_bus_info( - devices, follower_bus_info) + follower = find_device_with_bus_info(devices, follower_bus_info) # If this follower does not exist, that is ok # There is no inherent truth to the existance of the followers list if not follower: continue - # What is worse than it not existing, however, is it not being a follower - # So, we delete + # What is worse than it not existing, however, is it not being a + # follower. So, we delete if follower.device_type != DeviceType.STELLARHD_FOLLOWER: self.logger.warning( - f"Follower device {follower.bus_info} is not of follower type, skipping" + f"Follower device {follower.bus_info} is not of follower type, " + "skipping" ) saved.followers.remove(follower_bus_info) continue @@ -160,7 +170,7 @@ def link_followers(self, devices: List[Device]): follower = cast(SHDDevice, follower) leader.add_follower(follower) - def _save_device(self, saved_device: SavedDeviceModel): + def _save_device(self, saved_device: SavedDeviceModel) -> None: for dev in self.settings: if dev.bus_info == saved_device.bus_info: self.settings.remove(dev) @@ -173,13 +183,13 @@ def _save_device(self, saved_device: SavedDeviceModel): self.file_object.truncate() self.file_object.flush() - def _run_settings_sync(self): - while True: + def _run_settings_sync(self) -> None: + while self.is_running: for saved_device in self.to_save: self._save_device(saved_device) self.to_save = [] time.sleep(1) - def save_device(self, device: Device): + def save_device(self, device: Device) -> None: # schedule a save command self.to_save.append(SavedDeviceModel.model_validate(device)) diff --git a/backend_py/src/services/cameras/shd.py b/backend_py/src/services/cameras/shd.py index 78bd55ec..96029f05 100644 --- a/backend_py/src/services/cameras/shd.py +++ b/backend_py/src/services/cameras/shd.py @@ -4,52 +4,70 @@ Adds additional features to stellarHD devices """ +import collections +import logging +import queue import struct +import threading import time +from collections.abc import Callable +from enum import Enum +from typing import Any + from event_emitter import EventEmitter -from typing import Dict, List -from .saved_pydantic_schemas import SavedDeviceModel -from .enumeration import DeviceInfo -from .device import Device, BaseOption, ControlTypeEnum, StreamEncodeTypeEnum from . import xu_controls as xu -from typing import Callable, Any +from .device import BaseOption, ControlTypeEnum, Device, StreamEncodeTypeEnum +from .enumeration import DeviceInfo +from .saved_pydantic_schemas import SavedDeviceModel -import queue -import collections -import threading -from typing import Optional + +def get_val(addr: Enum | int) -> int: + if isinstance(addr, Enum): + return addr.value + return addr class StorageOption(BaseOption, EventEmitter): - def __init__(self, name: str, value): + def __init__(self, name: str, value) -> None: BaseOption.__init__(self, name) EventEmitter.__init__(self) - self.value = value + self.value: int | float = value - def set_value(self, value): + def set_value(self, value) -> None: self.value = value self.emit("value_changed") - def get_value(self): + def get_value(self) -> int | float: return self.value class CustomOption(BaseOption): - - def __init__(self, name: str, setter: Callable[[Any], None], getter: Callable[[], Any]): + def __init__( + self, + name: str, + setter: Callable[[Any], None], + getter: Callable[[], int | float | None], + is_integer_only=True, + ) -> None: BaseOption.__init__(self, name) self.setter = setter + self.is_integer_only = is_integer_only - # FIXME: I did this since the getter seems to be unreliable for asic controls, so we just trust the value stored + # FIXME: I did this since the getter seems to be unreliable for asic controls, + # so we just trust the value stored self.getter = getter - self.value = getter() + self.value: int | float | None = getter() + self.logger = logging.getLogger("CustomOption") - def set_value(self, value): + def set_value(self, value) -> None: + self.logger.info(f"{self.name}: {value}") + if self.is_integer_only: + value = int(value) self.setter(value) self.value = value - def get_value(self): + def get_value(self) -> int | float | None: return self.value @@ -70,21 +88,26 @@ def __init__(self, device_info: DeviceInfo) -> None: self._asic_worker_running = True self._asic_thread = threading.Thread( - target=self._asic_command_worker, daemon=True) + target=self._asic_command_worker, daemon=True + ) self._asic_thread.start() super().__init__(device_info) # Copy MJPEG over to Software H264, since they are the same thing mjpg_camera = self.find_camera_with_format("MJPG") + if not mjpg_camera: + raise RuntimeError( + "Failed to initialize stellarHD: MJPG camera format not found." + ) mjpg_camera.formats["SOFTWARE_H264"] = mjpg_camera.formats["MJPG"] # List of followers # Zero inherent truth to the existance of these devices - self.followers: List[str] = [] + self.followers: list[str] = [] # These exist - self.follower_devices: List['SHDDevice'] = [] + self.follower_devices: list[SHDDevice] = [] # Is true if it is managed, false otherwise self.is_managed = False @@ -95,27 +118,30 @@ def __init__(self, device_info: DeviceInfo) -> None: if self.is_pro: self.add_control_from_option( - 'shutter', 100, ControlTypeEnum.INTEGER, 8000, 10, 1 + "shutter", 100, ControlTypeEnum.INTEGER, 8000, 10, 1 ) + self.add_control_from_option("ae", False, ControlTypeEnum.BOOLEAN) + self.add_control_from_option( - 'ae', False, ControlTypeEnum.BOOLEAN + "iso", 400, ControlTypeEnum.INTEGER, 4095, 0, 1 ) self.add_control_from_option( - 'iso', 400, ControlTypeEnum.INTEGER, 4095, 0, 1 + "strobe_width", 0, ControlTypeEnum.INTEGER, 4095, 0, 1 ) self.add_control_from_option( - 'strobe_width', 0, ControlTypeEnum.INTEGER, 4095, 0, 1) + "hw_bitrate", 5000, ControlTypeEnum.INTEGER, 65535, 0, 1 + ) # self.add_control_from_option( # 'strobe_enabled', False, ControlTypeEnum.BOOLEAN) - def _asic_command_worker(self): - ''' + def _asic_command_worker(self) -> None: + """ Background worker that processes ASIC/Sensor commands sequentionally - ''' + """ while self._asic_worker_running: task = None @@ -137,15 +163,16 @@ def _asic_command_worker(self): if result_queue: result_queue.put(res) except Exception as e: - self.logger.error( - f"Error executing ASIC command ({key}): {e}") + self.logger.error(f"Error executing ASIC command ({key}): {e}") if result_queue: result_queue.put(None) # Enforce hardware delay time.sleep(self.ASIC_COMMAND_DELAY) - def _run_asic_command(self, key: Optional[str], func: Callable, args: tuple, wait: bool = True) -> Any: + def _run_asic_command( + self, key: str | None, func: Callable, args: tuple, wait: bool = True + ) -> Any: """ Helper to submit a command to the queue and wait for the result synchronously. """ @@ -155,54 +182,59 @@ def _run_asic_command(self, key: Optional[str], func: Callable, args: tuple, wai result_queue = queue.Queue() if wait else None with self._queue_cond: - if key is not None: + if key is not None and any(item[0] == key for item in self._command_queue): # Filter out previous pending commands of the same type # This implements the "ignore previous requests" logic # We rebuild the deque without the matching keys # Check if we even need to filter to avoid list overhead - if any(item[0] == key for item in self._command_queue): - # Filter existing items. - # Note: We only drop items that don't have a result_queue waiting - # (though in this design, keyed items are usually fire-and-forget writes) - new_queue = collections.deque() - while self._command_queue: - item = self._command_queue.popleft() - existing_key, _, _, existing_result_q = item - - # If keys match, we drop the OLD one. - # Ideally, we only drop if no one is waiting on it (wait=False). - # If wait=True, we probably shouldn't drop it, or we should send None to the queue. - if existing_key == key: - if existing_result_q: - # If something was waiting on the old command, release it - existing_result_q.put(None) - # Item is dropped - continue - - new_queue.append(item) - self._command_queue = new_queue + # Filter existing items. + # Note: We only drop items that don't have a result_queue waiting + # (though in this design, keyed items are usually fire-and-forget + # writes) + new_queue = collections.deque() + while self._command_queue: + item = self._command_queue.popleft() + existing_key, _, _, existing_result_q = item + + # If keys match, we drop the OLD one. + # Ideally, we only drop if no one is waiting on it (wait=False). + # If wait=True, we probably shouldn't drop it, or we should + # send None to the queue. + if existing_key == key: + if existing_result_q: + # If something was waiting on the old command, + # release it + existing_result_q.put(None) + # Item is dropped + continue + + new_queue.append(item) + self._command_queue = new_queue # Add the new command to the end self._command_queue.append((key, func, args, result_queue)) self._queue_cond.notify() - if wait: + if wait and result_queue: return result_queue.get() return None - def add_follower(self, device: 'SHDDevice'): + def add_follower(self, device: "SHDDevice") -> None: if device.bus_info in self.followers: self.logger.info( - 'Trying to add follower to device that already has this device as a follower. Ignoring request.') + "Trying to add follower to device that already has this device as a " + "follower. Ignoring request." + ) return if device.bus_info == self.bus_info: self.logger.info( - 'Trying to add follower of same bus id as self. This is not allowed.') + "Trying to add follower of same bus id as self. This is not allowed." + ) return - self.logger.info('Adding follower') + self.logger.info("Adding follower") # For saving purposes self.followers.append(device.bus_info) @@ -216,45 +248,46 @@ def add_follower(self, device: 'SHDDevice'): if self.stream.enabled: self.start_stream() - def remove_follower(self, device: 'SHDDevice'): - if not device.bus_info in self.followers: + def remove_follower(self, device: "SHDDevice") -> None: + if device.bus_info not in self.followers: self.logger.info( - "Cannot remove follower from device that does not contain it.") + "Cannot remove follower from device that does not contain it." + ) return # Reconstruct the list without the follower - self.followers = [ - dev for dev in self.followers if dev != device.bus_info] + self.followers = [dev for dev in self.followers if dev != device.bus_info] self.follower_devices = [ dev for dev in self.follower_devices if dev.bus_info != device.bus_info ] device.set_is_managed(False) - self.logger.info('Removing follower') + self.logger.info("Removing follower") if self.stream.enabled: self.start_stream() # ASIC stuff # Sensor writes are not supported by all firmwares - # Only recent stellarHD firmware, no exploreHD firmware - but explore does support asic writes as well + # Only recent stellarHD firmware, no exploreHD firmware - + # but explore does support asic writes as well - def _sensor_write_high_low(self, reg_high: int, reg_low: int, value: int): - ''' + def _sensor_write_high_low(self, reg_high: int, reg_low: int, value: int) -> None: + """ Write high byte from value to high register, low byte to low - ''' - self._sensor_write(reg_high, - (value >> 8) & 0xFF) - # This is extremely scuffed: switch to waiting for trigger register before release (See below) + """ + self._sensor_write(reg_high, (value >> 8) & 0xFF) + # This is extremely scuffed: switch to waiting for + # trigger register before release (See below) time.sleep(0.1) self._sensor_write(reg_low, value & 0xFF) # TODO: add check for success (0xAA in REG_TRIG) def _sensor_read_high_low(self, reg_high, reg_low) -> int | None: - ''' + """ Read high byte from high register to value, low byte to low - ''' + """ ret, high = self._sensor_read(reg_high) if ret != 0: return None @@ -264,7 +297,7 @@ def _sensor_read_high_low(self, reg_high, reg_low) -> int | None: return (high << 8) | (low & 0xFF) - def _sensor_write(self, reg: int, val: int): + def _sensor_write(self, reg: int, val: int) -> int: high = (reg >> 8) & 0xFF low = reg & 0xFF @@ -285,7 +318,7 @@ def _sensor_write(self, reg: int, val: int): return ret - def _sensor_read(self, reg: int): + def _sensor_read(self, reg: int) -> tuple[int, int]: high = (reg >> 8) & 0xFF low = reg & 0xFF @@ -303,33 +336,36 @@ def _sensor_read(self, reg: int): ret |= self._asic_write(xu.StellarRegisterMap.REG_TRIG, 0x55) if ret != 0: - return ret + return ret, -1 ret, val = self._asic_read(xu.StellarRegisterMap.REG_DATA) return ret, val - def _asic_write(self, addr: int | xu.StellarRegisterMap, data: int, dummy: bool = False) -> int: + def _asic_write( + self, addr: int | xu.StellarRegisterMap, data: int, dummy: bool = False + ) -> int: unit = xu.Unit.SYS_ID selector = xu.Selector.SYS_ASIC_RW # Accept enum - addr_val = addr.value if hasattr(addr, 'value') else addr + addr_val = get_val(addr) size = 4 # Dummy writes are used for asic reading write_mode = 0xFF if dummy else 0 - # Little endian unsigned short (asic address), byte (data), byte (write mode: 0 = normal, 0xFF = dummy) + # Little endian unsigned short (asic address), byte (data), + # byte (write mode: 0 = normal, 0xFF = dummy) ctrl_data = struct.pack(" tuple[int, int]: - addr_val = addr.value if hasattr(addr, 'value') else addr + addr_val = get_val(addr) # perform a dummy write to select the correct address ret = self._asic_write(addr_val, 0, True) if ret != 0: - return ret + return (ret, -1) unit = xu.Unit.SYS_ID selector = xu.Selector.SYS_ASIC_RW @@ -338,72 +374,165 @@ def _asic_read(self, addr: int | xu.StellarRegisterMap) -> tuple[int, int]: # address, data, dummy read ctrl_data = struct.pack(" int: + val_low = value & 0xFF + # TODO: return after first fails... (we dont even use the status anyway) + ret = self._asic_write(addr_low, val_low) + + val_high = value >> 8 & 0xFF + ret = self._asic_write(addr_high, val_high) + return ret + + def _asic_read_high_low( + self, + addr_high: int | xu.StellarRegisterMap, + addr_low: int | xu.StellarRegisterMap, + ) -> int | None: + ret, val_high = self._asic_read(addr_high) + ret, val_low = self._asic_read(addr_low) + if ret != 0: + return None + return val_high << 8 | val_low + + def remove_manual(self, follower_bus_info: str) -> None: + """ This should be called in the case the follower no longer exists - ''' + """ self.followers.remove(follower_bus_info) - def set_is_managed(self, is_managed: bool): + def set_is_managed(self, is_managed: bool) -> None: self.is_managed = is_managed # Configure stream if needbe - if not is_managed: - if self.stream.enabled: - self.start_stream() + if not is_managed and self.stream.enabled: + self.start_stream() # This goes against the architecture created in the exploreHD - # When we designed that, it was preferred to not have any functions that could control asic values. + # When we designed that, it was preferred to not have any functions that could + # control asic values. # TODO: FIXME - def set_shutter_speed(self, value: int): - self._run_asic_command('shutter', self._sensor_write_high_low, - (xu.StellarSensorMap.SHUTTER_HIGH, xu.StellarSensorMap.SHUTTER_LOW, int(value)), wait=False) + def set_shutter_speed(self, value: int) -> None: + self._run_asic_command( + "shutter", + self._sensor_write_high_low, + ( + xu.StellarSensorMap.SHUTTER_HIGH, + xu.StellarSensorMap.SHUTTER_LOW, + int(value), + ), + wait=False, + ) def get_shutter_speed(self) -> int | None: - return self._run_asic_command(None, self._sensor_read_high_low, - (xu.StellarSensorMap.SHUTTER_HIGH, xu.StellarSensorMap.SHUTTER_LOW), wait=True) + return self._run_asic_command( + None, + self._sensor_read_high_low, + (xu.StellarSensorMap.SHUTTER_HIGH, xu.StellarSensorMap.SHUTTER_LOW), + wait=True, + ) - def set_iso(self, value: int): - self._run_asic_command('iso', self._sensor_write_high_low, - (xu.StellarSensorMap.ISO_HIGH, xu.StellarSensorMap.ISO_LOW, int(value)), wait=False) + def set_iso(self, value: int) -> None: + self._run_asic_command( + "iso", + self._sensor_write_high_low, + (xu.StellarSensorMap.ISO_HIGH, xu.StellarSensorMap.ISO_LOW, int(value)), + wait=False, + ) def get_iso(self) -> int | None: - return self._run_asic_command(None, self._sensor_read_high_low, - (xu.StellarSensorMap.ISO_HIGH, xu.StellarSensorMap.ISO_LOW), wait=True) + return self._run_asic_command( + None, + self._sensor_read_high_low, + (xu.StellarSensorMap.ISO_HIGH, xu.StellarSensorMap.ISO_LOW), + wait=True, + ) - def set_asic_ae(self, enabled: bool): - self._run_asic_command('ae', self._asic_write, - (xu.StellarRegisterMap.REG_AE, 0x01 if enabled else 0x00), wait=False) + def set_asic_ae(self, enabled: bool) -> None: + self._run_asic_command( + "ae", + self._asic_write, + (xu.StellarRegisterMap.REG_AE, 0x01 if enabled else 0x00), + wait=False, + ) def get_asic_ae(self) -> bool | None: - # We can run asic read commands without worrying, since they don't write to the camera..? I think + # We can run asic read commands without worrying, since they don't write to the + # camera. - besides dummy writes ret, val = self._asic_read(xu.StellarRegisterMap.REG_AE) if ret != 0: return None - return True if val == 0x01 else False - - def set_strobe_width(self, value: int): - self._run_asic_command('strobe', self._sensor_write_high_low, - (xu.StellarSensorMap.STROBE_WIDTH_HIGH, xu.StellarSensorMap.STROBE_WIDTH_LOW, int(value)), wait=False) + return val == 0x01 + + def set_strobe_width(self, value: int) -> None: + self._run_asic_command( + "strobe", + self._sensor_write_high_low, + ( + xu.StellarSensorMap.STROBE_WIDTH_HIGH, + xu.StellarSensorMap.STROBE_WIDTH_LOW, + int(value), + ), + wait=False, + ) def get_strobe_width(self) -> int | None: - return self._run_asic_command(None, self._sensor_read_high_low, - (xu.StellarSensorMap.STROBE_WIDTH_HIGH, xu.StellarSensorMap.STROBE_WIDTH_LOW), wait=True) + return self._run_asic_command( + None, + self._sensor_read_high_low, + ( + xu.StellarSensorMap.STROBE_WIDTH_HIGH, + xu.StellarSensorMap.STROBE_WIDTH_LOW, + ), + wait=True, + ) + + def set_hw_bitrate(self, value: int) -> None: + self._run_asic_command( + "hw_bitrate", + self._asic_write_high_low, + ( + xu.StellarRegisterMap.REG_HW_BITRATE_HIGH, + xu.StellarRegisterMap.REG_HW_BITRATE_LOW, + value, + ), + ) + self._run_asic_command( + "hw_bitrate", + self._asic_write, + (xu.StellarRegisterMap.REG_HW_BITRATE_TRIG, 1), + ) + + def get_hw_bitrate(self) -> int | None: + return self._run_asic_command( + "hw_bitrate", + self._asic_read_high_low, + ( + xu.StellarRegisterMap.REG_HW_BITRATE_HIGH, + xu.StellarRegisterMap.REG_HW_BITRATE_LOW, + ), + wait=True, + ) - def _get_options(self) -> Dict[str, BaseOption]: + def _get_options(self) -> dict[str, BaseOption]: options = {} - self.bitrate_option = StorageOption( - "Software H.264 Bitrate", 5) # 5 mpbs + self.bitrate_option = StorageOption("Software H.264 Bitrate", 5) # 5 mpbs - def update_bitrate(): - if self.stream.enabled and self.stream.encode_type == StreamEncodeTypeEnum.SOFTWARE_H264: + def update_bitrate() -> None: + if ( + self.stream.enabled + and self.stream.encode_type == StreamEncodeTypeEnum.SOFTWARE_H264 + ): self.start_stream() # Only restart if it's being used @@ -415,54 +544,62 @@ def update_bitrate(): options["bitrate"] = self.bitrate_option if self.is_pro: - options['ae'] = CustomOption( + options["ae"] = CustomOption( "Auto Exposure (ASIC)", self.set_asic_ae, self.get_asic_ae ) # UVC shutter speed control - options['shutter'] = CustomOption( - "Shutter Speed", self.set_shutter_speed, self.get_shutter_speed) + options["shutter"] = CustomOption( + "Shutter Speed", self.set_shutter_speed, self.get_shutter_speed + ) # UVC ISO control - options['iso'] = CustomOption( - "ISO", self.set_iso, self.get_iso) + options["iso"] = CustomOption("ISO", self.set_iso, self.get_iso) # options['strobe_enabled'] = CustomOption( # "Strobe Enabled", self.set_strobe_enabled, self.get_strobe_enabled) - options['strobe_width'] = CustomOption( - "Strobe Width", self.set_strobe_width, self.get_strobe_width) + options["strobe_width"] = CustomOption( + "Strobe Width", self.set_strobe_width, self.get_strobe_width + ) + + options["hw_bitrate"] = CustomOption( + "HW Bitrate", self.set_hw_bitrate, self.get_hw_bitrate + ) return options - def load_settings(self, saved_device: SavedDeviceModel): - return super().load_settings(saved_device) + def load_settings(self, saved_device: SavedDeviceModel) -> None: + super().load_settings(saved_device) - def start_stream(self): + def start_stream(self) -> None: if self.is_managed: self.logger.warning( - f"{self.bus_info if not self.nickname else self.nickname}: Cannot start stream that is managed.") + f"{self.bus_info}: Cannot start stream that is managed." + ) return self.stream_runner.streams = [self.stream] for follower_device in self.follower_devices: - # A not so hacky fix (very clever :]) to ensure the stream's device_path is set - follower_device.configure_stream(self.stream.encode_type, self.stream.width, - self.stream.height, self.stream.interval, self.stream.stream_type, []) + # A not so hacky fix (very clever :]) to ensure the stream's device_path is + # set + follower_device.configure_stream( + self.stream.encode_type, + self.stream.width, + self.stream.height, + self.stream.interval, + self.stream.stream_type, + [], + ) # Append the new device stream self.stream_runner.streams.append(follower_device.stream) # mbps to kbit/sec - self.stream.software_h264_bitrate = int( - self.bitrate_option.get_value() * 1000 - ) + self.stream.software_h264_bitrate = int(self.bitrate_option.get_value() * 1000) super().start_stream() - def unconfigure_stream(self): - # remove leader when unconfiguring - if self.leader_device: - self.remove_leader() - return super().unconfigure_stream() + def unconfigure_stream(self) -> None: + super().unconfigure_stream() diff --git a/backend_py/src/services/cameras/stream_engines/base_stream_engine.py b/backend_py/src/services/cameras/stream_engines/base_stream_engine.py index 1da22081..97e975c6 100644 --- a/backend_py/src/services/cameras/stream_engines/base_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/base_stream_engine.py @@ -1,7 +1,8 @@ +import logging from abc import ABC, abstractmethod -from typing import List, Callable +from collections.abc import Callable + from .stream import Stream -import logging class BaseStreamEngine(ABC): @@ -9,17 +10,19 @@ class BaseStreamEngine(ABC): Abstract class for any streaming backend """ - def __init__(self, streams: List[Stream], error_callback: Callable[[str], None]): + def __init__( + self, streams: list[Stream], error_callback: Callable[[str], None] + ) -> None: super().__init__() self.streams = streams self.emit_error = error_callback - self.logger = logging.getLogger( - f"dwe_os_2.cameras.{self.__class__.__name__}") + self.logger = logging.getLogger(f"dwe_os_2.cameras.{self.__class__.__name__}") @abstractmethod - def start(self): + def start(self) -> None: pass - def stop(self): + @abstractmethod + def stop(self) -> None: pass diff --git a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py index bd2d6f6a..f75c42c2 100644 --- a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py @@ -1,16 +1,16 @@ import os -from .stream import Stream -from ..pydantic_schemas import StreamEncodeTypeEnum, StreamTypeEnum +import signal import stat import subprocess -from typing import Optional -import signal import threading from datetime import datetime + +from ..pydantic_schemas import StreamEncodeTypeEnum, StreamTypeEnum from .base_stream_engine import BaseStreamEngine +from .stream import Stream -class GStreamerPipelineBuilder(): +class GStreamerPipelineBuilder: """ Responsible for creation of GStreamer pipelines based on a Stream configuraiton """ @@ -24,7 +24,7 @@ def build(cls, stream: Stream) -> str: return f"{source} ! {caps} ! {payload} ! {sink}" @staticmethod - def _get_format(stream: Stream): + def _get_format(stream: Stream) -> str: match stream.encode_type: case StreamEncodeTypeEnum.H264: return "video/x-h264" @@ -36,19 +36,27 @@ def _get_format(stream: Stream): return "" @staticmethod - def _build_source(stream: Stream): + def _build_source(stream: Stream) -> str: return f"v4l2src device={stream.device_path}" @staticmethod - def _construct_caps(stream: Stream): - return f"{GStreamerPipelineBuilder._get_format(stream)},width={stream.width},height={stream.height},framerate={stream.interval.denominator}/{stream.interval.numerator}" + def _construct_caps(stream: Stream) -> str: + return ( + f"{GStreamerPipelineBuilder._get_format(stream)},width={stream.width}," + f"height={stream.height},framerate={stream.interval.denominator}/{stream.interval.numerator}" + ) @staticmethod - def _build_payload(stream: Stream): + def _build_payload(stream: Stream) -> str: match stream.encode_type: case StreamEncodeTypeEnum.H264: if stream.stream_type == StreamTypeEnum.RECORDING: - return f"h264parse ! video/x-h264,width={stream.width},height={stream.height},framerate={stream.interval.denominator}/{stream.interval.numerator} ! queue ! mp4mux" + return ( + f"h264parse ! video/x-h264,width={stream.width}," + f"height={stream.height}," + f"framerate={stream.interval.denominator}/" + f"{stream.interval.numerator} ! queue ! mp4mux" + ) else: return "h264parse ! queue ! rtph264pay config-interval=10 pt=96" case StreamEncodeTypeEnum.MJPG: @@ -58,37 +66,65 @@ def _build_payload(stream: Stream): return "rtpjpegpay" case StreamEncodeTypeEnum.SOFTWARE_H264: if stream.stream_type == StreamTypeEnum.RECORDING: - return f"jpegdec ! queue ! x264enc byte-stream=false tune=zerolatency bitrate={stream.software_h264_bitrate} speed-preset=ultrafast ! h264parse ! video/x-h264,width={stream.width},height={stream.height},framerate={stream.interval.denominator}/{stream.interval.numerator} ! queue ! mp4mux" + # FIXME: This was done to make it not exceed the 88 char limit for + # ruff. It looks bad, and can be solved with a better formatting + # system. + return ( + "jpegdec ! queue ! " + "x264enc byte-stream=false tune=zerolatency " + f"bitrate={stream.software_h264_bitrate} " + "speed-preset=ultrafast ! " + "h264parse ! video/x-h264," + f"width={stream.width},height={stream.height}," + f"framerate={stream.interval.denominator}" + f"/{stream.interval.numerator} ! queue ! mp4mux" + ) else: - return f"jpegdec ! queue ! x264enc byte-stream=true tune=zerolatency bitrate={stream.software_h264_bitrate} speed-preset=ultrafast ! rtph264pay config-interval=10 pt=96" + return ( + "jpegdec ! queue ! x264enc byte-stream=true " + f"tune=zerolatency bitrate={stream.software_h264_bitrate} " + "speed-preset=ultrafast ! rtph264pay " + "config-interval=10 pt=96" + ) case _: return "" - def _build_sink(stream: Stream): + @staticmethod + def _build_sink(stream: Stream) -> str: match stream.stream_type: case StreamTypeEnum.UDP: if len(stream.endpoints) == 0: return "fakesink" sink = "multiudpsink sync=true clients=" - for endpoint, i in zip(stream.endpoints, range(len(stream.endpoints))): - sink += f"{endpoint.host}:{endpoint.port}" - if i < len(stream.endpoints) - 1: - sink += "," - + sink += ",".join(f"{e.host}:{e.port}" for e in stream.endpoints) return sink case StreamTypeEnum.RECORDING: home_dir = os.getcwd() video_dir = os.path.join(home_dir, "videos") if not os.path.exists(video_dir): os.makedirs(video_dir) - permissions = stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH + permissions = ( + stat.S_IRWXU + | stat.S_IRGRP + | stat.S_IXGRP + | stat.S_IROTH + | stat.S_IXOTH + ) os.chmod(video_dir, permissions) - extension = "avi" if stream.encode_type == StreamEncodeTypeEnum.MJPG else "mp4" + extension = ( + "avi" if stream.encode_type == StreamEncodeTypeEnum.MJPG else "mp4" + ) timestamp = datetime.now().strftime("%F-%T") - unique_filename = f"{stream.device_path.split('/')[-1]}_{timestamp}.{extension}" + unique_filename = ( + f"{stream.device_path.split('/')[-1]}_{timestamp}.{extension}" + ) unique_path = os.path.join(video_dir, unique_filename) if os.path.exists(unique_path): - unique_filename = f"{stream.device_path.split('/')[-1]}_{timestamp}_{os.getpid()}.{extension}" + # TODO: use pathlib + unique_filename = ( + f"{stream.device_path.split('/')[-1]}" + f"_{timestamp}_{os.getpid()}.{extension}" + ) unique_path = os.path.join(video_dir, unique_filename) stream.file_path = unique_path return f"filesink location={unique_path} sync=true" @@ -101,31 +137,37 @@ class GStreamerProcessEngine(BaseStreamEngine): GStreamer stream Engine """ - def __init__(self, streams, error_callback): + def __init__(self, streams, error_callback) -> None: super().__init__(streams, error_callback) - self._process: Optional[subprocess.Popen] = None - self._error_thread: Optional[threading.thread] = None + self._process: subprocess.Popen | None = None + self._error_thread: threading.Thread | None = None self._lock = threading.RLock() self.started = False - def start(self): + def start(self) -> None: with self._lock: self.logger.info( - f"Starting stream for devices: {[stream.device_path for stream in self.streams]}") + "Starting stream for devices: " + f"{[stream.device_path for stream in self.streams]}" + ) if self.started: self.stop() self.started = True self._run_pipeline() - def _run_pipeline(self): + def _run_pipeline(self) -> None: pipeline_str = self._construct_pipeline() self.logger.info(pipeline_str) has_recording_stream = any( - stream.stream_type == StreamTypeEnum.RECORDING for stream in self.streams) + stream.stream_type == StreamTypeEnum.RECORDING for stream in self.streams + ) self._process = subprocess.Popen( - f"gst-launch-1.0 {'-e' if has_recording_stream else ''} {pipeline_str}".split( - " "), + [ + "gst-launch-1.0", + f"{'-e' if has_recording_stream else ''}", # EOS on shutdown + *pipeline_str.split(" "), + ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, @@ -133,7 +175,7 @@ def _run_pipeline(self): self._error_thread = threading.Thread(target=self._monitor_stderr) self._error_thread.start() - def stop(self): + def stop(self) -> None: with self._lock: if not self.started or not self._process: return @@ -143,7 +185,9 @@ def stop(self): # For recording streams, send EOS to properly finalize the file has_recording_stream = any( - stream.stream_type == StreamTypeEnum.RECORDING for stream in self.streams) + stream.stream_type == StreamTypeEnum.RECORDING + for stream in self.streams + ) try: if has_recording_stream: @@ -168,17 +212,25 @@ def _construct_pipeline(self) -> str: parts = [GStreamerPipelineBuilder.build(s) for s in self.streams] return " ".join(parts) - def _monitor_stderr(self): + def _monitor_stderr(self) -> None: error_block = [] - try: - for stderr_line in iter(self._process.stderr.readline, ""): - line_stripped = stderr_line.strip() - # Log all stderr output but only stop on actual errors - if any(error_keyword in line_stripped.lower() for error_keyword in ['error', 'failed', 'warning', 'critical']): - error_block.append(line_stripped) - except: - pass + if not self._process or not self._process.stderr: + self.logger.error( + "Unable to monitor stderr for process. " + "Is the GStreamer process running?" + ) + return + + for stderr_line in iter(self._process.stderr.readline, ""): + line_stripped = stderr_line.strip() + + # Log all stderr output but only stop on actual errors + if any( + error_keyword in line_stripped.lower() + for error_keyword in ["error", "failed", "warning", "critical"] + ): + error_block.append(line_stripped) if self._process: self._process.wait() @@ -186,8 +238,9 @@ def _monitor_stderr(self): if self.started and return_code != 0: self.logger.error( - f"GStreamer process crashed with return code: {return_code}") - + f"GStreamer process crashed with return code: {return_code}" + ) + for error in error_block: self.logger.error(error) diff --git a/backend_py/src/services/cameras/stream_engines/stream.py b/backend_py/src/services/cameras/stream_engines/stream.py index b789d778..b4047798 100644 --- a/backend_py/src/services/cameras/stream_engines/stream.py +++ b/backend_py/src/services/cameras/stream_engines/stream.py @@ -1,5 +1,11 @@ from dataclasses import dataclass, field -from ..pydantic_schemas import * + +from ..pydantic_schemas import ( + IntervalModel, + StreamEncodeTypeEnum, + StreamEndpointModel, + StreamTypeEnum, +) @dataclass @@ -7,10 +13,11 @@ class Stream: """ Pure configuration object for a video stream. """ + device_path: str = "" encode_type: StreamEncodeTypeEnum = StreamEncodeTypeEnum.NONE stream_type: StreamTypeEnum = StreamTypeEnum.UDP - endpoints: List[StreamEndpointModel] = field(default_factory=list) + endpoints: list[StreamEndpointModel] = field(default_factory=list) width: int = 1600 height: int = 1200 interval: IntervalModel = field( @@ -20,4 +27,4 @@ class Stream: # Configuration specific software_h264_bitrate: int = 5000 - file_path: Optional[str] = None + file_path: str | None = None diff --git a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py index c813d4e4..7bfd227d 100644 --- a/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/synchronized_stream_engine.py @@ -1,26 +1,22 @@ -from ..synchronized_camera import V4L2Camera, SynchronizedCamera, CopiedFrame -from ..pydantic_schemas import StreamEndpointModel -from rtp import RTP -import time -import struct +import collections import socket +import struct import threading import time -import collections -from typing import List +from ..pydantic_schemas import StreamEndpointModel +from ..synchronized_camera import CopiedFrame, SynchronizedCamera, V4L2Camera from .base_stream_engine import BaseStreamEngine -from .stream import Stream class SynchronizedStreamEngine(BaseStreamEngine): - - def __init__(self, streams, error_callback): + def __init__(self, streams, error_callback) -> None: super().__init__(streams, error_callback) self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.frame_queue: collections.deque[tuple[CopiedFrame, CopiedFrame]] = \ + self.frame_queue: collections.deque[tuple[CopiedFrame, CopiedFrame]] = ( collections.deque(maxlen=2) + ) self.MTU = 1400 self.SSRC = 0x445745 # "DWE" @@ -34,8 +30,15 @@ def __init__(self, streams, error_callback): # Always MJPEG try: - self.cameras: List[V4L2Camera] = [V4L2Camera( - stream.device_path, stream.width, stream.height, stream.interval.denominator) for stream in streams] + self.cameras: list[V4L2Camera] = [ + V4L2Camera( + stream.device_path, + stream.width, + stream.height, + stream.interval.denominator, + ) + for stream in streams + ] self.synchronized_camera = SynchronizedCamera(self.cameras) except OSError as e: @@ -43,16 +46,23 @@ def __init__(self, streams, error_callback): if e.strerror: self.emit_error(e.strerror) - # Assisted with this code. Custom RTP improves performance compared to RTP class - def _send_frame(self, frames: List[CopiedFrame], endpoint: StreamEndpointModel): + # Custom RTP improves performance compared to RTP class + def _send_frame( + self, frames: list[CopiedFrame], endpoint: StreamEndpointModel + ) -> None: # TODO: change protocol to handle more than two cameras - assert len(frames) == 2 + if len(frames) > 2: + self.logger.warning( + "Only 2 cameras are supported for synchronized streaming. " + "This is an in progress feature- please contact us if you " + "require it sooner." + ) + left_frame = frames[0] right_frame = frames[1] # payload headers - frame_header = struct.pack(" None: self.logger.info( - f"Starting synchronized stream with: {(', '.join([stream.device_path for stream in self.streams]))}") + "Starting synchronized stream with: " + f"{(', '.join([stream.device_path for stream in self.streams]))}" + ) # self.logger.warning("SynchronizedStreamEngine is not yet implemented") if len(self.streams) != 2: self.logger.error( - "SynchronizedStreamEngine cannot support more than 2 streams yet!") + "SynchronizedStreamEngine cannot support more than 2 streams yet!" + ) return if not self.synchronized_camera: self.logger.error( - "Synchronized camera does not exist. An error occurred previously in construction!") + "Synchronized camera does not exist. An error occurred previously in " + "construction!" + ) return - self.capture_thread = threading.Thread( - target=self.capture_loop_) + self.capture_thread = threading.Thread(target=self.capture_loop_) self._running = True self.capture_thread.start() # We cannot handle more than 2 synchronized streams yet in the protocol - self.stream_thread = threading.Thread( - target=self.stream_loop_) + self.stream_thread = threading.Thread(target=self.stream_loop_) self.stream_thread.start() - def stop(self): + def stop(self) -> None: try: self._running = False @@ -134,26 +145,26 @@ def stop(self): if self.stream_thread: self.stream_thread.join(timeout=1000) except TimeoutError as e: - self.logger.error( - f"Timeout exceeded while joining capture thread: {e}") + self.logger.error(f"Timeout exceeded while joining capture thread: {e}") - def capture_loop_(self): + def capture_loop_(self) -> None: if not self.synchronized_camera: self.logger.error( - "Cannot run capture loop when synchronized camera is not defined!") + "Cannot run capture loop when synchronized camera is not defined!" + ) return # We need to be careful about the blocking aspect of grab while self._running: frames = self.synchronized_camera.grab() if frames is None: - time.sleep(0.01) + time.sleep(1 / self.streams[0].interval.denominator) continue self.frame_queue.append((frames[0], frames[1])) self.synchronized_camera.stop() - def stream_loop_(self): + def stream_loop_(self) -> None: while self._running: try: endpoint = self.streams[0].endpoints[0] @@ -165,5 +176,5 @@ def stream_loop_(self): # TODO: make less scuffed self._send_frame([left, right], endpoint) except IndexError: - time.sleep(0.01) + time.sleep(1 / self.streams[0].interval.denominator) continue diff --git a/backend_py/src/services/cameras/stream_runner.py b/backend_py/src/services/cameras/stream_runner.py index 65d9b64c..b0e01a51 100644 --- a/backend_py/src/services/cameras/stream_runner.py +++ b/backend_py/src/services/cameras/stream_runner.py @@ -2,21 +2,24 @@ stream_runner.py """ +import logging import threading +import time + import event_emitter as events -import logging -from .stream_engines.stream import Stream + from .stream_engines.base_stream_engine import BaseStreamEngine -from .stream_engines.synchronized_stream_engine import SynchronizedStreamEngine from .stream_engines.gstreamer_stream_engine import GStreamerProcessEngine -import time +from .stream_engines.stream import Stream +from .stream_engines.synchronized_stream_engine import SynchronizedStreamEngine class StreamRunner(events.EventEmitter): """ The main entry point. Automatically decides which engine to use based on usage. - Streams are expected to be added dynamically. Calling start() will construct the correct engine on the fly with the provided streams. + Streams are expected to be added dynamically. Calling start() will construct the + correct engine on the fly with the provided streams. """ def __init__(self, *streams: Stream) -> None: @@ -35,28 +38,30 @@ def _select_engine(self) -> BaseStreamEngine: if len(self.streams) > 1: self.logger.info( - "Multiple streams detected: Using SynchronizedStreamEngine.") + "Multiple streams detected: Using SynchronizedStreamEngine." + ) return SynchronizedStreamEngine(self.streams, self._on_engine_error) else: - self.logger.info( - "Single stream detected: Using GStreamerProcessEngine.") + self.logger.info("Single stream detected: Using GStreamerProcessEngine.") return GStreamerProcessEngine(self.streams, self._on_engine_error) - def _on_engine_error(self, error_data): + def _on_engine_error(self, error_data) -> None: """Callback to bubble up errors from the engine to the runner's listeners.""" # TODO: change to general stream error self.emit("stream_error", error_data) self.stop() - def start(self): + def start(self) -> None: with self._lock: self.logger.info( - f"Starting streams: {[s.device_path for s in self.streams]}") + f"Starting streams: {[s.device_path for s in self.streams]}" + ) if self.started: self.stop() time.sleep(1) - # We create the engine on start, so the engine can perform initial setup on constructor + # We create the engine on start, so the engine can perform initial setup on + # constructor self.engine: BaseStreamEngine = self._select_engine() self.started = True @@ -67,7 +72,7 @@ def start(self): self.logger.error(f"Failed to start engine: {e}") self.started = False - def stop(self): + def stop(self) -> None: with self._lock: if not self.started: return diff --git a/backend_py/src/services/cameras/stream_utils.py b/backend_py/src/services/cameras/stream_utils.py index 68ccd198..dd52346a 100644 --- a/backend_py/src/services/cameras/stream_utils.py +++ b/backend_py/src/services/cameras/stream_utils.py @@ -1,27 +1,30 @@ from .pydantic_schemas import StreamEncodeTypeEnum -def fourcc2s(fourcc: int): - res = '' - res += chr(fourcc & 0x7f) - res += chr((fourcc >> 8) & 0x7f) - res += chr((fourcc >> 16) & 0x7f) - res += chr((fourcc >> 24) & 0x7f) + +def fourcc2s(fourcc: int) -> str: + res = "" + res += chr(fourcc & 0x7F) + res += chr((fourcc >> 8) & 0x7F) + res += chr((fourcc >> 16) & 0x7F) + res += chr((fourcc >> 24) & 0x7F) if fourcc & (1 << 31): - res += '-BE' + res += "-BE" return res -def stream_encode_type_to_string(encode_type: StreamEncodeTypeEnum): + +def stream_encode_type_to_string(encode_type: StreamEncodeTypeEnum) -> str | None: match encode_type: case StreamEncodeTypeEnum.MJPG: - return 'MJPG' + return "MJPG" case StreamEncodeTypeEnum.H264: - return 'H264' + return "H264" return None -def string_to_stream_encode_type(encoding: str): + +def string_to_stream_encode_type(encoding: str) -> StreamEncodeTypeEnum: match encoding: - case 'MJPG': + case "MJPG": return StreamEncodeTypeEnum.MJPG - case 'H264': + case "H264": return StreamEncodeTypeEnum.H264 - return None \ No newline at end of file + return StreamEncodeTypeEnum.NONE diff --git a/backend_py/src/services/cameras/synchronized_camera/__init__.py b/backend_py/src/services/cameras/synchronized_camera/__init__.py index 12247150..0e7e4141 100644 --- a/backend_py/src/services/cameras/synchronized_camera/__init__.py +++ b/backend_py/src/services/cameras/synchronized_camera/__init__.py @@ -1 +1,3 @@ -from .lib import * +from .lib import CopiedFrame, SynchronizedCamera, V4L2Camera + +__all__ = ["SynchronizedCamera", "V4L2Camera", "CopiedFrame"] diff --git a/backend_py/src/services/cameras/synchronized_camera/lib.py b/backend_py/src/services/cameras/synchronized_camera/lib.py index 4279c5b2..9e5f4710 100644 --- a/backend_py/src/services/cameras/synchronized_camera/lib.py +++ b/backend_py/src/services/cameras/synchronized_camera/lib.py @@ -2,15 +2,14 @@ # # Minimal Python V4L2 capture + multi-camera synchronizer -import os +import contextlib +import ctypes import fcntl +import logging import mmap -import time -from dataclasses import dataclass +import os from collections import deque -from typing import List, Optional -import ctypes -import logging +from dataclasses import dataclass from .. import v4l2 @@ -26,6 +25,7 @@ class CopiedFrame: pixel_format number defining the format of the image (currently always jpeg) timestamp_us the timestamp of the frame in microseconds """ + data: bytes width: int height: int @@ -38,12 +38,15 @@ class V4L2Camera: Python V4L2 camera wrapper using mmap buffers. """ - def __init__(self, device: str, - width: int, - height: int, - fps: int, - pixel_format: int = v4l2.V4L2_PIX_FMT_MJPEG, - buffer_count: int = 4): + def __init__( + self, + device: str, + width: int, + height: int, + fps: int, + pixel_format: int = v4l2.V4L2_PIX_FMT_MJPEG, + buffer_count: int = 4, + ) -> None: self.device = device self.width = width @@ -69,8 +72,9 @@ def __init__(self, device: str, self._queue_all_buffers() self._start_stream() - def _ioctl(self, req, arg): - assert self.fd is not None + def _ioctl(self, req, arg) -> int: + if self.fd is None: + raise RuntimeError("V4L2Camera.fd is not defined. Unable to run ioctl.") try: return fcntl.ioctl(self.fd, req, arg) @@ -78,7 +82,7 @@ def _ioctl(self, req, arg): self.logger.error(f"IOCTL error: {e.strerror}") return -1 - def _set_format(self): + def _set_format(self) -> None: fmt = v4l2.v4l2_format() fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE fmt.fmt.pix.width = self.width @@ -88,7 +92,7 @@ def _set_format(self): self._ioctl(v4l2.VIDIOC_S_FMT, fmt) - def _set_fps(self): + def _set_fps(self) -> None: parm = v4l2.v4l2_streamparm() parm.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE @@ -97,16 +101,16 @@ def _set_fps(self): # Check capability if not (parm.parm.capture.capability & v4l2.V4L2_CAP_TIMEPERFRAME): - raise RuntimeError( - "Camera does not support FPS control (TIMEPERFRAME).") + raise RuntimeError("Camera does not support FPS control (TIMEPERFRAME).") parm.parm.capture.timeperframe.numerator = 1 parm.parm.capture.timeperframe.denominator = self.fps self._ioctl(v4l2.VIDIOC_S_PARM, parm) - def _request_and_map_buffers(self): - assert self.fd is not None + def _request_and_map_buffers(self) -> None: + if self.fd is None: + raise RuntimeError("V4L2Camera.fd is not defined. Unable to map buffers.") req = v4l2.v4l2_requestbuffers() req.count = self.buffer_count @@ -116,9 +120,11 @@ def _request_and_map_buffers(self): self._ioctl(v4l2.VIDIOC_REQBUFS, req) if req.count != self.buffer_count: - # Count: `The number of buffers requested or granted.` (can rarely change after request) + # Count: `The number of buffers requested or granted.` + # (can rarely change after request) # Driver might reduce the buffer count? - # Might want to research this, because I'd bet it only happens with EXTREMELY large buffer counts + # Might want to research this, because I'd bet it only happens with + # EXTREMELY large buffer counts self.buffer_count = req.count self._buffers = [] @@ -141,7 +147,7 @@ def _request_and_map_buffers(self): ) self._buffers.append(mm) - def _queue_all_buffers(self): + def _queue_all_buffers(self) -> None: for i in range(self.buffer_count): buf = v4l2.v4l2_buffer() buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE @@ -149,12 +155,12 @@ def _queue_all_buffers(self): buf.index = i self._ioctl(v4l2.VIDIOC_QBUF, buf) - def _start_stream(self): + def _start_stream(self) -> None: buf_type = ctypes.c_int(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE) self._ioctl(v4l2.VIDIOC_STREAMON, buf_type) self._running = True - def _stop_stream(self): + def _stop_stream(self) -> None: if not self._running: return buf_type = ctypes.c_int(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE) @@ -163,36 +169,33 @@ def _stop_stream(self): # Public API - def grab_copied_frame(self, blocking: bool = True, timeout_s: float = 1.0) -> Optional[CopiedFrame]: + def grab_copied_frame( + self, blocking: bool = True, timeout_s: float = 1.0 + ) -> CopiedFrame | None: """ Dequeue one buffer, copy its contents into a new bytes object, requeue the buffer, and return a CopiedFrame. If blocking=False, returns None immediately if no frame is ready. """ + if blocking: + import select + + # select() polling + readable, _, _ = select.select([self.fd], [], [], timeout_s) + if not readable: + self.logger.warning(f"Timeout waiting for frame on {self.device}") + return None + buf = v4l2.v4l2_buffer() buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE buf.memory = v4l2.V4L2_MEMORY_MMAP - # We emulate blocking behavior with a small poll loop - # Actual polling is possible but not needed - start_time = time.time() - while True: - if self._ioctl(v4l2.VIDIOC_DQBUF, buf) != -1: - break - else: - if not blocking: - return None - if timeout_s is not None and (time.time() - start_time) > timeout_s: - return None - # EAGAIN: no buffer ready yet, sleep a bit - # FIXME: I don't really like this, but it'll do for the time being - time.sleep(0.001) + if self._ioctl(v4l2.VIDIOC_DQBUF, buf) == -1: + return None mm = self._buffers[buf.index] - # Copy only the used bytes - frame_bytes = mm[:buf.bytesused] - # Convert timeval (tv_sec, tv_usec) to microseconds + frame_bytes = mm[: buf.bytesused] ts_us = buf.timestamp.secs * 1_000_000 + buf.timestamp.usecs # Requeue the buffer immediately @@ -206,7 +209,7 @@ def grab_copied_frame(self, blocking: bool = True, timeout_s: float = 1.0) -> Op timestamp_us=ts_us, ) - def close(self): + def close(self) -> None: if self.critical_error: return @@ -214,10 +217,9 @@ def close(self): # Unmap buffers for mm in self._buffers: - try: + # We don't care if it fails + with contextlib.suppress(Exception): mm.close() - except Exception: - pass self._buffers.clear() @@ -234,15 +236,15 @@ def close(self): os.close(self.fd) self.fd = None - def __enter__(self): + def __enter__(self) -> "V4L2Camera": # Cool return self - def __exit__(self, exc_type, exc, tb): + def __exit__(self, exc_type, exc, tb) -> None: # We don't use this, but is useful in the case of with v4l2_camera as... self.close() - def __del__(self): + def __del__(self) -> None: self.close() @@ -251,31 +253,32 @@ class SynchronizedCamera: Synchronized Camera Class """ - def __init__(self, - cameras: List[V4L2Camera], - queue_cap: int = 8): + def __init__(self, cameras: list[V4L2Camera], queue_cap: int = 8) -> None: for camera in cameras: if camera.critical_error: raise AssertionError( - "Cannot create a SynchronizedCamera with cameras containing errors! Please check your camera status.") + "Cannot create a SynchronizedCamera with cameras containing errors!" + "Please check your camera status." + ) self.cameras = cameras sync_threshold_us = 1.0 / self.cameras[0].fps * 1000000 self.sync_threshold_us = sync_threshold_us self.queue_cap = queue_cap - self.queues: List[deque[CopiedFrame]] = [ - deque() for _ in cameras - ] - self.logger = logging.getLogger( - f"dwe_os_2.cameras.SynchronizedCamera") - - # For those curious about the synchronization logic, it can be summarized as follows: - # The synch threshold is **NOT** the precision. It is generally specified as 1/FPS. - # For 60 fps, this is 16667. If synchronized to within 1/FPS, the frames can be considered synchronized at the sensor level. - # The frames are captured at precisely the same time, but become jumbled when they reach the userspace API. - # As the Linux kernel is not an RTOS, we have no way of strictly forcing provided frames to be synchronized, - # but we can make it happen after the fact with kernel timestamps. + self.queues: list[deque[CopiedFrame]] = [deque() for _ in cameras] + self.logger = logging.getLogger("dwe_os_2.cameras.SynchronizedCamera") + + # For those curious about the synchronization logic, + # it can be summarized as follows: + # - The sync threshold is **NOT** the precision. It is specified as 1/FPS. + # - For 60 fps, this is 16667. If synchronized to within 1/FPS, the frames + # can be considered synchronized at the sensor level. + # - The frames are captured at precisely the same time, + # but become jumbled when they reach the userspace API. + # - As the Linux kernel is not an RTOS, we have no way of strictly + # forcing provided frames to be synchronized, but we can make it + # happen after the fact with kernel timestamps. def camera_count(self) -> int: return len(self.cameras) @@ -283,11 +286,11 @@ def camera_count(self) -> int: def _queues_full(self) -> bool: return all(len(q) > 0 for q in self.queues) - def stop(self): + def stop(self) -> None: for cam in self.cameras: cam.close() - def grab(self) -> Optional[List[CopiedFrame]]: + def grab(self) -> list[CopiedFrame] | None: """ Grab and synchronize frames from all cameras. Returns a list[CopiedFrame] of length camera_count() if synced, @@ -295,7 +298,7 @@ def grab(self) -> Optional[List[CopiedFrame]]: """ # grab one frame from each camera - grabbed_frames: List[CopiedFrame] = [] + grabbed_frames: list[CopiedFrame] = [] for cam in self.cameras: cf = cam.grab_copied_frame(blocking=True) if cf is None: @@ -313,8 +316,9 @@ def grab(self) -> Optional[List[CopiedFrame]]: # attempt synchronization while self._queues_full(): - timestamps = [self.queues[i] - [0].timestamp_us for i in range(self.camera_count())] + timestamps = [ + self.queues[i][0].timestamp_us for i in range(self.camera_count()) + ] min_ts = min(timestamps) max_ts = max(timestamps) @@ -327,8 +331,7 @@ def grab(self) -> Optional[List[CopiedFrame]]: else: # Drop the earliest frame (smallest timestamp) and try again min_index = timestamps.index(min_ts) - self.logger.info( - f"Dropping frame of difference: {max_ts - min_ts}") + self.logger.info(f"Dropping frame of difference: {max_ts - min_ts}") self.queues[min_index].popleft() # Not enough frames anymore to sync diff --git a/backend_py/src/services/cameras/v4l2.py b/backend_py/src/services/cameras/v4l2.py index 90a33658..50e529ea 100644 --- a/backend_py/src/services/cameras/v4l2.py +++ b/backend_py/src/services/cameras/v4l2.py @@ -48,7 +48,6 @@ import ctypes - _IOC_NRBITS = 8 _IOC_TYPEBITS = 8 _IOC_SIZEBITS = 14 @@ -66,10 +65,11 @@ def _IOC(dir_, type_, nr, size): return ( - ctypes.c_int32(dir_ << _IOC_DIRSHIFT).value | - ctypes.c_int32(ord(type_) << _IOC_TYPESHIFT).value | - ctypes.c_int32(nr << _IOC_NRSHIFT).value | - ctypes.c_int32(size << _IOC_SIZESHIFT).value) + ctypes.c_int32(dir_ << _IOC_DIRSHIFT).value + | ctypes.c_int32(ord(type_) << _IOC_TYPESHIFT).value + | ctypes.c_int32(nr << _IOC_NRSHIFT).value + | ctypes.c_int32(size << _IOC_SIZESHIFT).value + ) def _IOC_TYPECHECK(t): @@ -104,10 +104,11 @@ def _IOWR(type_, nr, size): # time # + class timeval(ctypes.Structure): _fields_ = [ - ('secs', ctypes.c_long), - ('usecs', ctypes.c_long), + ("secs", ctypes.c_long), + ("usecs", ctypes.c_long), ] @@ -156,31 +157,34 @@ def v4l2_fourcc(a, b, c, d): def V4L2_FIELD_HAS_TOP(field): return ( - field == V4L2_FIELD_TOP or - field == V4L2_FIELD_INTERLACED or - field == V4L2_FIELD_INTERLACED_TB or - field == V4L2_FIELD_INTERLACED_BT or - field == V4L2_FIELD_SEQ_TB or - field == V4L2_FIELD_SEQ_BT) + field == V4L2_FIELD_TOP + or field == V4L2_FIELD_INTERLACED + or field == V4L2_FIELD_INTERLACED_TB + or field == V4L2_FIELD_INTERLACED_BT + or field == V4L2_FIELD_SEQ_TB + or field == V4L2_FIELD_SEQ_BT + ) def V4L2_FIELD_HAS_BOTTOM(field): return ( - field == V4L2_FIELD_BOTTOM or - field == V4L2_FIELD_INTERLACED or - field == V4L2_FIELD_INTERLACED_TB or - field == V4L2_FIELD_INTERLACED_BT or - field == V4L2_FIELD_SEQ_TB or - field == V4L2_FIELD_SEQ_BT) + field == V4L2_FIELD_BOTTOM + or field == V4L2_FIELD_INTERLACED + or field == V4L2_FIELD_INTERLACED_TB + or field == V4L2_FIELD_INTERLACED_BT + or field == V4L2_FIELD_SEQ_TB + or field == V4L2_FIELD_SEQ_BT + ) def V4L2_FIELD_HAS_BOTH(field): return ( - field == V4L2_FIELD_INTERLACED or - field == V4L2_FIELD_INTERLACED_TB or - field == V4L2_FIELD_INTERLACED_BT or - field == V4L2_FIELD_SEQ_TB or - field == V4L2_FIELD_SEQ_BT) + field == V4L2_FIELD_INTERLACED + or field == V4L2_FIELD_INTERLACED_TB + or field == V4L2_FIELD_INTERLACED_BT + or field == V4L2_FIELD_SEQ_TB + or field == V4L2_FIELD_SEQ_BT + ) v4l2_buf_type = enum @@ -250,17 +254,17 @@ def V4L2_FIELD_HAS_BOTH(field): class v4l2_rect(ctypes.Structure): _fields_ = [ - ('left', ctypes.c_int32), - ('top', ctypes.c_int32), - ('width', ctypes.c_int32), - ('height', ctypes.c_int32), + ("left", ctypes.c_int32), + ("top", ctypes.c_int32), + ("width", ctypes.c_int32), + ("height", ctypes.c_int32), ] class v4l2_fract(ctypes.Structure): _fields_ = [ - ('numerator', ctypes.c_uint32), - ('denominator', ctypes.c_uint32), + ("numerator", ctypes.c_uint32), + ("denominator", ctypes.c_uint32), ] @@ -268,14 +272,15 @@ class v4l2_fract(ctypes.Structure): # Driver capabilities # + class v4l2_capability(ctypes.Structure): _fields_ = [ - ('driver', ctypes.c_char * 16), - ('card', ctypes.c_char * 32), - ('bus_info', ctypes.c_char * 32), - ('version', ctypes.c_uint32), - ('capabilities', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("driver", ctypes.c_char * 16), + ("card", ctypes.c_char * 32), + ("bus_info", ctypes.c_char * 32), + ("version", ctypes.c_uint32), + ("capabilities", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] @@ -309,117 +314,119 @@ class v4l2_capability(ctypes.Structure): # Video image format # + class v4l2_pix_format(ctypes.Structure): _fields_ = [ - ('width', ctypes.c_uint32), - ('height', ctypes.c_uint32), - ('pixelformat', ctypes.c_uint32), - ('field', v4l2_field), - ('bytesperline', ctypes.c_uint32), - ('sizeimage', ctypes.c_uint32), - ('colorspace', v4l2_colorspace), - ('priv', ctypes.c_uint32), + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), + ("pixelformat", ctypes.c_uint32), + ("field", v4l2_field), + ("bytesperline", ctypes.c_uint32), + ("sizeimage", ctypes.c_uint32), + ("colorspace", v4l2_colorspace), + ("priv", ctypes.c_uint32), ] # RGB formats -V4L2_PIX_FMT_RGB332 = v4l2_fourcc('R', 'G', 'B', '1') -V4L2_PIX_FMT_RGB444 = v4l2_fourcc('R', '4', '4', '4') -V4L2_PIX_FMT_RGB555 = v4l2_fourcc('R', 'G', 'B', 'O') -V4L2_PIX_FMT_RGB565 = v4l2_fourcc('R', 'G', 'B', 'P') -V4L2_PIX_FMT_RGB555X = v4l2_fourcc('R', 'G', 'B', 'Q') -V4L2_PIX_FMT_RGB565X = v4l2_fourcc('R', 'G', 'B', 'R') -V4L2_PIX_FMT_BGR24 = v4l2_fourcc('B', 'G', 'R', '3') -V4L2_PIX_FMT_RGB24 = v4l2_fourcc('R', 'G', 'B', '3') -V4L2_PIX_FMT_BGR32 = v4l2_fourcc('B', 'G', 'R', '4') -V4L2_PIX_FMT_RGB32 = v4l2_fourcc('R', 'G', 'B', '4') +V4L2_PIX_FMT_RGB332 = v4l2_fourcc("R", "G", "B", "1") +V4L2_PIX_FMT_RGB444 = v4l2_fourcc("R", "4", "4", "4") +V4L2_PIX_FMT_RGB555 = v4l2_fourcc("R", "G", "B", "O") +V4L2_PIX_FMT_RGB565 = v4l2_fourcc("R", "G", "B", "P") +V4L2_PIX_FMT_RGB555X = v4l2_fourcc("R", "G", "B", "Q") +V4L2_PIX_FMT_RGB565X = v4l2_fourcc("R", "G", "B", "R") +V4L2_PIX_FMT_BGR24 = v4l2_fourcc("B", "G", "R", "3") +V4L2_PIX_FMT_RGB24 = v4l2_fourcc("R", "G", "B", "3") +V4L2_PIX_FMT_BGR32 = v4l2_fourcc("B", "G", "R", "4") +V4L2_PIX_FMT_RGB32 = v4l2_fourcc("R", "G", "B", "4") # Grey formats -V4L2_PIX_FMT_GREY = v4l2_fourcc('G', 'R', 'E', 'Y') -V4L2_PIX_FMT_Y10 = v4l2_fourcc('Y', '1', '0', ' ') -V4L2_PIX_FMT_Y16 = v4l2_fourcc('Y', '1', '6', ' ') +V4L2_PIX_FMT_GREY = v4l2_fourcc("G", "R", "E", "Y") +V4L2_PIX_FMT_Y10 = v4l2_fourcc("Y", "1", "0", " ") +V4L2_PIX_FMT_Y16 = v4l2_fourcc("Y", "1", "6", " ") # Palette formats -V4L2_PIX_FMT_PAL8 = v4l2_fourcc('P', 'A', 'L', '8') +V4L2_PIX_FMT_PAL8 = v4l2_fourcc("P", "A", "L", "8") # Luminance+Chrominance formats -V4L2_PIX_FMT_YVU410 = v4l2_fourcc('Y', 'V', 'U', '9') -V4L2_PIX_FMT_YVU420 = v4l2_fourcc('Y', 'V', '1', '2') -V4L2_PIX_FMT_YUYV = v4l2_fourcc('Y', 'U', 'Y', 'V') -V4L2_PIX_FMT_YYUV = v4l2_fourcc('Y', 'Y', 'U', 'V') -V4L2_PIX_FMT_YVYU = v4l2_fourcc('Y', 'V', 'Y', 'U') -V4L2_PIX_FMT_UYVY = v4l2_fourcc('U', 'Y', 'V', 'Y') -V4L2_PIX_FMT_VYUY = v4l2_fourcc('V', 'Y', 'U', 'Y') -V4L2_PIX_FMT_YUV422P = v4l2_fourcc('4', '2', '2', 'P') -V4L2_PIX_FMT_YUV411P = v4l2_fourcc('4', '1', '1', 'P') -V4L2_PIX_FMT_Y41P = v4l2_fourcc('Y', '4', '1', 'P') -V4L2_PIX_FMT_YUV444 = v4l2_fourcc('Y', '4', '4', '4') -V4L2_PIX_FMT_YUV555 = v4l2_fourcc('Y', 'U', 'V', 'O') -V4L2_PIX_FMT_YUV565 = v4l2_fourcc('Y', 'U', 'V', 'P') -V4L2_PIX_FMT_YUV32 = v4l2_fourcc('Y', 'U', 'V', '4') -V4L2_PIX_FMT_YUV410 = v4l2_fourcc('Y', 'U', 'V', '9') -V4L2_PIX_FMT_YUV420 = v4l2_fourcc('Y', 'U', '1', '2') -V4L2_PIX_FMT_HI240 = v4l2_fourcc('H', 'I', '2', '4') -V4L2_PIX_FMT_HM12 = v4l2_fourcc('H', 'M', '1', '2') +V4L2_PIX_FMT_YVU410 = v4l2_fourcc("Y", "V", "U", "9") +V4L2_PIX_FMT_YVU420 = v4l2_fourcc("Y", "V", "1", "2") +V4L2_PIX_FMT_YUYV = v4l2_fourcc("Y", "U", "Y", "V") +V4L2_PIX_FMT_YYUV = v4l2_fourcc("Y", "Y", "U", "V") +V4L2_PIX_FMT_YVYU = v4l2_fourcc("Y", "V", "Y", "U") +V4L2_PIX_FMT_UYVY = v4l2_fourcc("U", "Y", "V", "Y") +V4L2_PIX_FMT_VYUY = v4l2_fourcc("V", "Y", "U", "Y") +V4L2_PIX_FMT_YUV422P = v4l2_fourcc("4", "2", "2", "P") +V4L2_PIX_FMT_YUV411P = v4l2_fourcc("4", "1", "1", "P") +V4L2_PIX_FMT_Y41P = v4l2_fourcc("Y", "4", "1", "P") +V4L2_PIX_FMT_YUV444 = v4l2_fourcc("Y", "4", "4", "4") +V4L2_PIX_FMT_YUV555 = v4l2_fourcc("Y", "U", "V", "O") +V4L2_PIX_FMT_YUV565 = v4l2_fourcc("Y", "U", "V", "P") +V4L2_PIX_FMT_YUV32 = v4l2_fourcc("Y", "U", "V", "4") +V4L2_PIX_FMT_YUV410 = v4l2_fourcc("Y", "U", "V", "9") +V4L2_PIX_FMT_YUV420 = v4l2_fourcc("Y", "U", "1", "2") +V4L2_PIX_FMT_HI240 = v4l2_fourcc("H", "I", "2", "4") +V4L2_PIX_FMT_HM12 = v4l2_fourcc("H", "M", "1", "2") # two planes -- one Y, one Cr + Cb interleaved -V4L2_PIX_FMT_NV12 = v4l2_fourcc('N', 'V', '1', '2') -V4L2_PIX_FMT_NV21 = v4l2_fourcc('N', 'V', '2', '1') -V4L2_PIX_FMT_NV16 = v4l2_fourcc('N', 'V', '1', '6') -V4L2_PIX_FMT_NV61 = v4l2_fourcc('N', 'V', '6', '1') +V4L2_PIX_FMT_NV12 = v4l2_fourcc("N", "V", "1", "2") +V4L2_PIX_FMT_NV21 = v4l2_fourcc("N", "V", "2", "1") +V4L2_PIX_FMT_NV16 = v4l2_fourcc("N", "V", "1", "6") +V4L2_PIX_FMT_NV61 = v4l2_fourcc("N", "V", "6", "1") # Bayer formats - see http://www.siliconimaging.com/RGB%20Bayer.htm -V4L2_PIX_FMT_SBGGR8 = v4l2_fourcc('B', 'A', '8', '1') -V4L2_PIX_FMT_SGBRG8 = v4l2_fourcc('G', 'B', 'R', 'G') -V4L2_PIX_FMT_SGRBG8 = v4l2_fourcc('G', 'R', 'B', 'G') -V4L2_PIX_FMT_SRGGB8 = v4l2_fourcc('R', 'G', 'G', 'B') -V4L2_PIX_FMT_SBGGR10 = v4l2_fourcc('B', 'G', '1', '0') -V4L2_PIX_FMT_SGBRG10 = v4l2_fourcc('G', 'B', '1', '0') -V4L2_PIX_FMT_SGRBG10 = v4l2_fourcc('B', 'A', '1', '0') -V4L2_PIX_FMT_SRGGB10 = v4l2_fourcc('R', 'G', '1', '0') -V4L2_PIX_FMT_SGRBG10DPCM8 = v4l2_fourcc('B', 'D', '1', '0') -V4L2_PIX_FMT_SBGGR16 = v4l2_fourcc('B', 'Y', 'R', '2') +V4L2_PIX_FMT_SBGGR8 = v4l2_fourcc("B", "A", "8", "1") +V4L2_PIX_FMT_SGBRG8 = v4l2_fourcc("G", "B", "R", "G") +V4L2_PIX_FMT_SGRBG8 = v4l2_fourcc("G", "R", "B", "G") +V4L2_PIX_FMT_SRGGB8 = v4l2_fourcc("R", "G", "G", "B") +V4L2_PIX_FMT_SBGGR10 = v4l2_fourcc("B", "G", "1", "0") +V4L2_PIX_FMT_SGBRG10 = v4l2_fourcc("G", "B", "1", "0") +V4L2_PIX_FMT_SGRBG10 = v4l2_fourcc("B", "A", "1", "0") +V4L2_PIX_FMT_SRGGB10 = v4l2_fourcc("R", "G", "1", "0") +V4L2_PIX_FMT_SGRBG10DPCM8 = v4l2_fourcc("B", "D", "1", "0") +V4L2_PIX_FMT_SBGGR16 = v4l2_fourcc("B", "Y", "R", "2") # compressed formats -V4L2_PIX_FMT_MJPEG = v4l2_fourcc('M', 'J', 'P', 'G') -V4L2_PIX_FMT_JPEG = v4l2_fourcc('J', 'P', 'E', 'G') -V4L2_PIX_FMT_DV = v4l2_fourcc('d', 'v', 's', 'd') -V4L2_PIX_FMT_MPEG = v4l2_fourcc('M', 'P', 'E', 'G') +V4L2_PIX_FMT_MJPEG = v4l2_fourcc("M", "J", "P", "G") +V4L2_PIX_FMT_JPEG = v4l2_fourcc("J", "P", "E", "G") +V4L2_PIX_FMT_DV = v4l2_fourcc("d", "v", "s", "d") +V4L2_PIX_FMT_MPEG = v4l2_fourcc("M", "P", "E", "G") # Vendor-specific formats -V4L2_PIX_FMT_CPIA1 = v4l2_fourcc('C', 'P', 'I', 'A') -V4L2_PIX_FMT_WNVA = v4l2_fourcc('W', 'N', 'V', 'A') -V4L2_PIX_FMT_SN9C10X = v4l2_fourcc('S', '9', '1', '0') -V4L2_PIX_FMT_SN9C20X_I420 = v4l2_fourcc('S', '9', '2', '0') -V4L2_PIX_FMT_PWC1 = v4l2_fourcc('P', 'W', 'C', '1') -V4L2_PIX_FMT_PWC2 = v4l2_fourcc('P', 'W', 'C', '2') -V4L2_PIX_FMT_ET61X251 = v4l2_fourcc('E', '6', '2', '5') -V4L2_PIX_FMT_SPCA501 = v4l2_fourcc('S', '5', '0', '1') -V4L2_PIX_FMT_SPCA505 = v4l2_fourcc('S', '5', '0', '5') -V4L2_PIX_FMT_SPCA508 = v4l2_fourcc('S', '5', '0', '8') -V4L2_PIX_FMT_SPCA561 = v4l2_fourcc('S', '5', '6', '1') -V4L2_PIX_FMT_PAC207 = v4l2_fourcc('P', '2', '0', '7') -V4L2_PIX_FMT_MR97310A = v4l2_fourcc('M', '3', '1', '0') -V4L2_PIX_FMT_SN9C2028 = v4l2_fourcc('S', 'O', 'N', 'X') -V4L2_PIX_FMT_SQ905C = v4l2_fourcc('9', '0', '5', 'C') -V4L2_PIX_FMT_PJPG = v4l2_fourcc('P', 'J', 'P', 'G') -V4L2_PIX_FMT_OV511 = v4l2_fourcc('O', '5', '1', '1') -V4L2_PIX_FMT_OV518 = v4l2_fourcc('O', '5', '1', '8') -V4L2_PIX_FMT_STV0680 = v4l2_fourcc('S', '6', '8', '0') +V4L2_PIX_FMT_CPIA1 = v4l2_fourcc("C", "P", "I", "A") +V4L2_PIX_FMT_WNVA = v4l2_fourcc("W", "N", "V", "A") +V4L2_PIX_FMT_SN9C10X = v4l2_fourcc("S", "9", "1", "0") +V4L2_PIX_FMT_SN9C20X_I420 = v4l2_fourcc("S", "9", "2", "0") +V4L2_PIX_FMT_PWC1 = v4l2_fourcc("P", "W", "C", "1") +V4L2_PIX_FMT_PWC2 = v4l2_fourcc("P", "W", "C", "2") +V4L2_PIX_FMT_ET61X251 = v4l2_fourcc("E", "6", "2", "5") +V4L2_PIX_FMT_SPCA501 = v4l2_fourcc("S", "5", "0", "1") +V4L2_PIX_FMT_SPCA505 = v4l2_fourcc("S", "5", "0", "5") +V4L2_PIX_FMT_SPCA508 = v4l2_fourcc("S", "5", "0", "8") +V4L2_PIX_FMT_SPCA561 = v4l2_fourcc("S", "5", "6", "1") +V4L2_PIX_FMT_PAC207 = v4l2_fourcc("P", "2", "0", "7") +V4L2_PIX_FMT_MR97310A = v4l2_fourcc("M", "3", "1", "0") +V4L2_PIX_FMT_SN9C2028 = v4l2_fourcc("S", "O", "N", "X") +V4L2_PIX_FMT_SQ905C = v4l2_fourcc("9", "0", "5", "C") +V4L2_PIX_FMT_PJPG = v4l2_fourcc("P", "J", "P", "G") +V4L2_PIX_FMT_OV511 = v4l2_fourcc("O", "5", "1", "1") +V4L2_PIX_FMT_OV518 = v4l2_fourcc("O", "5", "1", "8") +V4L2_PIX_FMT_STV0680 = v4l2_fourcc("S", "6", "8", "0") # # Format enumeration # + class v4l2_fmtdesc(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('type', ctypes.c_int), - ('flags', ctypes.c_uint32), - ('description', ctypes.c_char * 32), - ('pixelformat', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("index", ctypes.c_uint32), + ("type", ctypes.c_int), + ("flags", ctypes.c_uint32), + ("description", ctypes.c_char * 32), + ("pixelformat", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] @@ -441,38 +448,38 @@ class v4l2_fmtdesc(ctypes.Structure): class v4l2_frmsize_discrete(ctypes.Structure): _fields_ = [ - ('width', ctypes.c_uint32), - ('height', ctypes.c_uint32), + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), ] class v4l2_frmsize_stepwise(ctypes.Structure): _fields_ = [ - ('min_width', ctypes.c_uint32), - ('min_height', ctypes.c_uint32), - ('step_width', ctypes.c_uint32), - ('min_height', ctypes.c_uint32), - ('max_height', ctypes.c_uint32), - ('step_height', ctypes.c_uint32), + ("min_width", ctypes.c_uint32), + ("min_height", ctypes.c_uint32), + ("step_width", ctypes.c_uint32), + ("min_height", ctypes.c_uint32), + ("max_height", ctypes.c_uint32), + ("step_height", ctypes.c_uint32), ] class v4l2_frmsizeenum(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('discrete', v4l2_frmsize_discrete), - ('stepwise', v4l2_frmsize_stepwise), + ("discrete", v4l2_frmsize_discrete), + ("stepwise", v4l2_frmsize_stepwise), ] _fields_ = [ - ('index', ctypes.c_uint32), - ('pixel_format', ctypes.c_uint32), - ('type', ctypes.c_uint32), - ('_u', _u), - ('reserved', ctypes.c_uint32 * 2) + ("index", ctypes.c_uint32), + ("pixel_format", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("_u", _u), + ("reserved", ctypes.c_uint32 * 2), ] - _anonymous_ = ('_u',) + _anonymous_ = ("_u",) # @@ -489,45 +496,46 @@ class _u(ctypes.Union): class v4l2_frmival_stepwise(ctypes.Structure): _fields_ = [ - ('min', v4l2_fract), - ('max', v4l2_fract), - ('step', v4l2_fract), + ("min", v4l2_fract), + ("max", v4l2_fract), + ("step", v4l2_fract), ] class v4l2_frmivalenum(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('discrete', v4l2_fract), - ('stepwise', v4l2_frmival_stepwise), + ("discrete", v4l2_fract), + ("stepwise", v4l2_frmival_stepwise), ] _fields_ = [ - ('index', ctypes.c_uint32), - ('pixel_format', ctypes.c_uint32), - ('width', ctypes.c_uint32), - ('height', ctypes.c_uint32), - ('type', ctypes.c_uint32), - ('_u', _u), - ('reserved', ctypes.c_uint32 * 2), + ("index", ctypes.c_uint32), + ("pixel_format", ctypes.c_uint32), + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("_u", _u), + ("reserved", ctypes.c_uint32 * 2), ] - _anonymous_ = ('_u',) + _anonymous_ = ("_u",) # # Timecode # + class v4l2_timecode(ctypes.Structure): _fields_ = [ - ('type', ctypes.c_uint32), - ('flags', ctypes.c_uint32), - ('frames', ctypes.c_uint8), - ('seconds', ctypes.c_uint8), - ('minutes', ctypes.c_uint8), - ('hours', ctypes.c_uint8), - ('userbits', ctypes.c_uint8 * 4), + ("type", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("frames", ctypes.c_uint8), + ("seconds", ctypes.c_uint8), + ("minutes", ctypes.c_uint8), + ("hours", ctypes.c_uint8), + ("userbits", ctypes.c_uint8 * 4), ] @@ -546,13 +554,13 @@ class v4l2_timecode(ctypes.Structure): class v4l2_jpegcompression(ctypes.Structure): _fields_ = [ - ('quality', ctypes.c_int), - ('APPn', ctypes.c_int), - ('APP_len', ctypes.c_int), - ('APP_data', ctypes.c_char * 60), - ('COM_len', ctypes.c_int), - ('COM_data', ctypes.c_char * 60), - ('jpeg_markers', ctypes.c_uint32), + ("quality", ctypes.c_int), + ("APPn", ctypes.c_int), + ("APP_len", ctypes.c_int), + ("APP_data", ctypes.c_char * 60), + ("COM_len", ctypes.c_int), + ("COM_data", ctypes.c_char * 60), + ("jpeg_markers", ctypes.c_uint32), ] @@ -567,36 +575,37 @@ class v4l2_jpegcompression(ctypes.Structure): # Memory-mapping buffers # + class v4l2_requestbuffers(ctypes.Structure): _fields_ = [ - ('count', ctypes.c_uint32), - ('type', v4l2_buf_type), - ('memory', v4l2_memory), - ('reserved', ctypes.c_uint32 * 2), + ("count", ctypes.c_uint32), + ("type", v4l2_buf_type), + ("memory", v4l2_memory), + ("reserved", ctypes.c_uint32 * 2), ] class v4l2_buffer(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('offset', ctypes.c_uint32), - ('userptr', ctypes.c_ulong), + ("offset", ctypes.c_uint32), + ("userptr", ctypes.c_ulong), ] _fields_ = [ - ('index', ctypes.c_uint32), - ('type', v4l2_buf_type), - ('bytesused', ctypes.c_uint32), - ('flags', ctypes.c_uint32), - ('field', v4l2_field), - ('timestamp', timeval), - ('timecode', v4l2_timecode), - ('sequence', ctypes.c_uint32), - ('memory', v4l2_memory), - ('m', _u), - ('length', ctypes.c_uint32), - ('input', ctypes.c_uint32), - ('reserved', ctypes.c_uint32), + ("index", ctypes.c_uint32), + ("type", v4l2_buf_type), + ("bytesused", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("field", v4l2_field), + ("timestamp", timeval), + ("timecode", v4l2_timecode), + ("sequence", ctypes.c_uint32), + ("memory", v4l2_memory), + ("m", _u), + ("length", ctypes.c_uint32), + ("input", ctypes.c_uint32), + ("reserved", ctypes.c_uint32), ] @@ -614,12 +623,13 @@ class _u(ctypes.Union): # Overlay preview # + class v4l2_framebuffer(ctypes.Structure): _fields_ = [ - ('capability', ctypes.c_uint32), - ('flags', ctypes.c_uint32), - ('base', ctypes.c_void_p), - ('fmt', v4l2_pix_format), + ("capability", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("base", ctypes.c_void_p), + ("fmt", v4l2_pix_format), ] @@ -646,20 +656,20 @@ class v4l2_clip(ctypes.Structure): v4l2_clip._fields_ = [ - ('c', v4l2_rect), - ('next', ctypes.POINTER(v4l2_clip)), + ("c", v4l2_rect), + ("next", ctypes.POINTER(v4l2_clip)), ] class v4l2_window(ctypes.Structure): _fields_ = [ - ('w', v4l2_rect), - ('field', v4l2_field), - ('chromakey', ctypes.c_uint32), - ('clips', ctypes.POINTER(v4l2_clip)), - ('clipcount', ctypes.c_uint32), - ('bitmap', ctypes.c_void_p), - ('global_alpha', ctypes.c_uint8), + ("w", v4l2_rect), + ("field", v4l2_field), + ("chromakey", ctypes.c_uint32), + ("clips", ctypes.POINTER(v4l2_clip)), + ("clipcount", ctypes.c_uint32), + ("bitmap", ctypes.c_void_p), + ("global_alpha", ctypes.c_uint8), ] @@ -667,14 +677,15 @@ class v4l2_window(ctypes.Structure): # Capture parameters # + class v4l2_captureparm(ctypes.Structure): _fields_ = [ - ('capability', ctypes.c_uint32), - ('capturemode', ctypes.c_uint32), - ('timeperframe', v4l2_fract), - ('extendedmode', ctypes.c_uint32), - ('readbuffers', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("capability", ctypes.c_uint32), + ("capturemode", ctypes.c_uint32), + ("timeperframe", v4l2_fract), + ("extendedmode", ctypes.c_uint32), + ("readbuffers", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] @@ -684,12 +695,12 @@ class v4l2_captureparm(ctypes.Structure): class v4l2_outputparm(ctypes.Structure): _fields_ = [ - ('capability', ctypes.c_uint32), - ('outputmode', ctypes.c_uint32), - ('timeperframe', v4l2_fract), - ('extendedmode', ctypes.c_uint32), - ('writebuffers', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("capability", ctypes.c_uint32), + ("outputmode", ctypes.c_uint32), + ("timeperframe", v4l2_fract), + ("extendedmode", ctypes.c_uint32), + ("writebuffers", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] @@ -697,19 +708,20 @@ class v4l2_outputparm(ctypes.Structure): # Input image cropping # + class v4l2_cropcap(ctypes.Structure): _fields_ = [ - ('type', v4l2_buf_type), - ('bounds', v4l2_rect), - ('defrect', v4l2_rect), - ('pixelaspect', v4l2_fract), + ("type", v4l2_buf_type), + ("bounds", v4l2_rect), + ("defrect", v4l2_rect), + ("pixelaspect", v4l2_fract), ] class v4l2_crop(ctypes.Structure): _fields_ = [ - ('type', ctypes.c_int), - ('c', v4l2_rect), + ("type", ctypes.c_int), + ("c", v4l2_rect), ] @@ -753,41 +765,42 @@ class v4l2_crop(ctypes.Structure): # some common needed stuff -V4L2_STD_PAL_BG = (V4L2_STD_PAL_B | V4L2_STD_PAL_B1 | V4L2_STD_PAL_G) -V4L2_STD_PAL_DK = (V4L2_STD_PAL_D | V4L2_STD_PAL_D1 | V4L2_STD_PAL_K) -V4L2_STD_PAL = (V4L2_STD_PAL_BG | V4L2_STD_PAL_DK | - V4L2_STD_PAL_H | V4L2_STD_PAL_I) -V4L2_STD_NTSC = (V4L2_STD_NTSC_M | V4L2_STD_NTSC_M_JP | V4L2_STD_NTSC_M_KR) -V4L2_STD_SECAM_DK = (V4L2_STD_SECAM_D | V4L2_STD_SECAM_K | V4L2_STD_SECAM_K1) -V4L2_STD_SECAM = (V4L2_STD_SECAM_B | V4L2_STD_SECAM_G | V4L2_STD_SECAM_H | - V4L2_STD_SECAM_DK | V4L2_STD_SECAM_L | V4L2_STD_SECAM_LC) - -V4L2_STD_525_60 = (V4L2_STD_PAL_M | V4L2_STD_PAL_60 | - V4L2_STD_NTSC | V4L2_STD_NTSC_443) -V4L2_STD_625_50 = (V4L2_STD_PAL | V4L2_STD_PAL_N | - V4L2_STD_PAL_Nc | V4L2_STD_SECAM) -V4L2_STD_ATSC = (V4L2_STD_ATSC_8_VSB | V4L2_STD_ATSC_16_VSB) +V4L2_STD_PAL_BG = V4L2_STD_PAL_B | V4L2_STD_PAL_B1 | V4L2_STD_PAL_G +V4L2_STD_PAL_DK = V4L2_STD_PAL_D | V4L2_STD_PAL_D1 | V4L2_STD_PAL_K +V4L2_STD_PAL = V4L2_STD_PAL_BG | V4L2_STD_PAL_DK | V4L2_STD_PAL_H | V4L2_STD_PAL_I +V4L2_STD_NTSC = V4L2_STD_NTSC_M | V4L2_STD_NTSC_M_JP | V4L2_STD_NTSC_M_KR +V4L2_STD_SECAM_DK = V4L2_STD_SECAM_D | V4L2_STD_SECAM_K | V4L2_STD_SECAM_K1 +V4L2_STD_SECAM = ( + V4L2_STD_SECAM_B + | V4L2_STD_SECAM_G + | V4L2_STD_SECAM_H + | V4L2_STD_SECAM_DK + | V4L2_STD_SECAM_L + | V4L2_STD_SECAM_LC +) + +V4L2_STD_525_60 = V4L2_STD_PAL_M | V4L2_STD_PAL_60 | V4L2_STD_NTSC | V4L2_STD_NTSC_443 +V4L2_STD_625_50 = V4L2_STD_PAL | V4L2_STD_PAL_N | V4L2_STD_PAL_Nc | V4L2_STD_SECAM +V4L2_STD_ATSC = V4L2_STD_ATSC_8_VSB | V4L2_STD_ATSC_16_VSB V4L2_STD_UNKNOWN = 0 -V4L2_STD_ALL = (V4L2_STD_525_60 | V4L2_STD_625_50) +V4L2_STD_ALL = V4L2_STD_525_60 | V4L2_STD_625_50 # some merged standards -V4L2_STD_MN = (V4L2_STD_PAL_M | V4L2_STD_PAL_N | - V4L2_STD_PAL_Nc | V4L2_STD_NTSC) -V4L2_STD_B = (V4L2_STD_PAL_B | V4L2_STD_PAL_B1 | V4L2_STD_SECAM_B) -V4L2_STD_GH = (V4L2_STD_PAL_G | V4L2_STD_PAL_H | - V4L2_STD_SECAM_G | V4L2_STD_SECAM_H) -V4L2_STD_DK = (V4L2_STD_PAL_DK | V4L2_STD_SECAM_DK) +V4L2_STD_MN = V4L2_STD_PAL_M | V4L2_STD_PAL_N | V4L2_STD_PAL_Nc | V4L2_STD_NTSC +V4L2_STD_B = V4L2_STD_PAL_B | V4L2_STD_PAL_B1 | V4L2_STD_SECAM_B +V4L2_STD_GH = V4L2_STD_PAL_G | V4L2_STD_PAL_H | V4L2_STD_SECAM_G | V4L2_STD_SECAM_H +V4L2_STD_DK = V4L2_STD_PAL_DK | V4L2_STD_SECAM_DK class v4l2_standard(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('id', v4l2_std_id), - ('name', ctypes.c_char * 24), - ('frameperiod', v4l2_fract), - ('framelines', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("index", ctypes.c_uint32), + ("id", v4l2_std_id), + ("name", ctypes.c_char * 24), + ("frameperiod", v4l2_fract), + ("framelines", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] @@ -795,27 +808,27 @@ class v4l2_standard(ctypes.Structure): # Video timings dv preset # + class v4l2_dv_preset(ctypes.Structure): - _fields_ = [ - ('preset', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4) - ] + _fields_ = [("preset", ctypes.c_uint32), ("reserved", ctypes.c_uint32 * 4)] # # DV preset enumeration # + class v4l2_dv_enum_preset(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('preset', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('width', ctypes.c_uint32), - ('height', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("index", ctypes.c_uint32), + ("preset", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] + # # DV preset values # @@ -846,23 +859,24 @@ class v4l2_dv_enum_preset(ctypes.Structure): # DV BT timings # + class v4l2_bt_timings(ctypes.Structure): _fields_ = [ - ('width', ctypes.c_uint32), - ('height', ctypes.c_uint32), - ('interlaced', ctypes.c_uint32), - ('polarities', ctypes.c_uint32), - ('pixelclock', ctypes.c_uint64), - ('hfrontporch', ctypes.c_uint32), - ('hsync', ctypes.c_uint32), - ('hbackporch', ctypes.c_uint32), - ('vfrontporch', ctypes.c_uint32), - ('vsync', ctypes.c_uint32), - ('vbackporch', ctypes.c_uint32), - ('il_vfrontporch', ctypes.c_uint32), - ('il_vsync', ctypes.c_uint32), - ('il_vbackporch', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 16), + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), + ("interlaced", ctypes.c_uint32), + ("polarities", ctypes.c_uint32), + ("pixelclock", ctypes.c_uint64), + ("hfrontporch", ctypes.c_uint32), + ("hsync", ctypes.c_uint32), + ("hbackporch", ctypes.c_uint32), + ("vfrontporch", ctypes.c_uint32), + ("vsync", ctypes.c_uint32), + ("vbackporch", ctypes.c_uint32), + ("il_vfrontporch", ctypes.c_uint32), + ("il_vsync", ctypes.c_uint32), + ("il_vbackporch", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 16), ] _pack_ = True @@ -880,16 +894,16 @@ class v4l2_bt_timings(ctypes.Structure): class v4l2_dv_timings(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('bt', v4l2_bt_timings), - ('reserved', ctypes.c_uint32 * 32), + ("bt", v4l2_bt_timings), + ("reserved", ctypes.c_uint32 * 32), ] _fields_ = [ - ('type', ctypes.c_uint32), - ('_u', _u), + ("type", ctypes.c_uint32), + ("_u", _u), ] - _anonymous_ = ('_u',) + _anonymous_ = ("_u",) _pack_ = True @@ -901,16 +915,17 @@ class _u(ctypes.Union): # Video inputs # + class v4l2_input(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('type', ctypes.c_uint32), - ('audioset', ctypes.c_uint32), - ('tuner', ctypes.c_uint32), - ('std', v4l2_std_id), - ('status', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("index", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("type", ctypes.c_uint32), + ("audioset", ctypes.c_uint32), + ("tuner", ctypes.c_uint32), + ("std", v4l2_std_id), + ("status", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] @@ -946,13 +961,13 @@ class v4l2_input(ctypes.Structure): class v4l2_output(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('type', ctypes.c_uint32), - ('audioset', ctypes.c_uint32), - ('modulator', ctypes.c_uint32), - ('std', v4l2_std_id), - ('reserved', ctypes.c_uint32 * 4), + ("index", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("type", ctypes.c_uint32), + ("audioset", ctypes.c_uint32), + ("modulator", ctypes.c_uint32), + ("std", v4l2_std_id), + ("reserved", ctypes.c_uint32 * 4), ] @@ -971,77 +986,73 @@ class v4l2_output(ctypes.Structure): class v4l2_control(ctypes.Structure): _fields_ = [ - ('id', ctypes.c_uint32), - ('value', ctypes.c_int32), + ("id", ctypes.c_uint32), + ("value", ctypes.c_int32), ] class v4l2_ext_control(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('value', ctypes.c_int32), - ('value64', ctypes.c_int64), - ('reserved', ctypes.c_void_p), + ("value", ctypes.c_int32), + ("value64", ctypes.c_int64), + ("reserved", ctypes.c_void_p), ] - _fields_ = [ - ('id', ctypes.c_uint32), - ('reserved2', ctypes.c_uint32 * 2), - ('_u', _u) - ] + _fields_ = [("id", ctypes.c_uint32), ("reserved2", ctypes.c_uint32 * 2), ("_u", _u)] - _anonymous_ = ('_u',) + _anonymous_ = ("_u",) _pack_ = True class v4l2_ext_controls(ctypes.Structure): _fields_ = [ - ('ctrl_class', ctypes.c_uint32), - ('count', ctypes.c_uint32), - ('error_idx', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 2), - ('controls', ctypes.POINTER(v4l2_ext_control)), + ("ctrl_class", ctypes.c_uint32), + ("count", ctypes.c_uint32), + ("error_idx", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2), + ("controls", ctypes.POINTER(v4l2_ext_control)), ] V4L2_CTRL_CLASS_USER = 0x00980000 V4L2_CTRL_CLASS_MPEG = 0x00990000 -V4L2_CTRL_CLASS_CAMERA = 0x009a0000 -V4L2_CTRL_CLASS_FM_TX = 0x009b0000 +V4L2_CTRL_CLASS_CAMERA = 0x009A0000 +V4L2_CTRL_CLASS_FM_TX = 0x009B0000 def V4L2_CTRL_ID_MASK(): - return 0x0fffffff + return 0x0FFFFFFF def V4L2_CTRL_ID2CLASS(id_): - return id_ & 0x0fff0000 # unsigned long + return id_ & 0x0FFF0000 # unsigned long def V4L2_CTRL_DRIVER_PRIV(id_): - return (id_ & 0xffff) >= 0x1000 + return (id_ & 0xFFFF) >= 0x1000 class v4l2_queryctrl(ctypes.Structure): _fields_ = [ - ('id', ctypes.c_uint32), - ('type', v4l2_ctrl_type), - ('name', ctypes.c_char * 32), - ('minimum', ctypes.c_int32), - ('maximum', ctypes.c_int32), - ('step', ctypes.c_int32), - ('default', ctypes.c_int32), - ('flags', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 2), + ("id", ctypes.c_uint32), + ("type", v4l2_ctrl_type), + ("name", ctypes.c_char * 32), + ("minimum", ctypes.c_int32), + ("maximum", ctypes.c_int32), + ("step", ctypes.c_int32), + ("default", ctypes.c_int32), + ("flags", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2), ] class v4l2_querymenu(ctypes.Structure): _fields_ = [ - ('id', ctypes.c_uint32), - ('index', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('reserved', ctypes.c_uint32), + ("id", ctypes.c_uint32), + ("index", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("reserved", ctypes.c_uint32), ] @@ -1458,31 +1469,32 @@ class v4l2_querymenu(ctypes.Structure): # Tuning # + class v4l2_tuner(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('type', v4l2_tuner_type), - ('capability', ctypes.c_uint32), - ('rangelow', ctypes.c_uint32), - ('rangehigh', ctypes.c_uint32), - ('rxsubchans', ctypes.c_uint32), - ('audmode', ctypes.c_uint32), - ('signal', ctypes.c_int32), - ('afc', ctypes.c_int32), - ('reserved', ctypes.c_uint32 * 4), + ("index", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("type", v4l2_tuner_type), + ("capability", ctypes.c_uint32), + ("rangelow", ctypes.c_uint32), + ("rangehigh", ctypes.c_uint32), + ("rxsubchans", ctypes.c_uint32), + ("audmode", ctypes.c_uint32), + ("signal", ctypes.c_int32), + ("afc", ctypes.c_int32), + ("reserved", ctypes.c_uint32 * 4), ] class v4l2_modulator(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('capability', ctypes.c_uint32), - ('rangelow', ctypes.c_uint32), - ('rangehigh', ctypes.c_uint32), - ('txsubchans', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), + ("index", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("capability", ctypes.c_uint32), + ("rangelow", ctypes.c_uint32), + ("rangehigh", ctypes.c_uint32), + ("txsubchans", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), ] @@ -1511,20 +1523,20 @@ class v4l2_modulator(ctypes.Structure): class v4l2_frequency(ctypes.Structure): _fields_ = [ - ('tuner', ctypes.c_uint32), - ('type', v4l2_tuner_type), - ('frequency', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 8), + ("tuner", ctypes.c_uint32), + ("type", v4l2_tuner_type), + ("frequency", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 8), ] class v4l2_hw_freq_seek(ctypes.Structure): _fields_ = [ - ('tuner', ctypes.c_uint32), - ('type', v4l2_tuner_type), - ('seek_upward', ctypes.c_uint32), - ('wrap_around', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 8), + ("tuner", ctypes.c_uint32), + ("type", v4l2_tuner_type), + ("seek_upward", ctypes.c_uint32), + ("wrap_around", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 8), ] @@ -1532,11 +1544,12 @@ class v4l2_hw_freq_seek(ctypes.Structure): # RDS # + class v4l2_rds_data(ctypes.Structure): _fields_ = [ - ('lsb', ctypes.c_char), - ('msb', ctypes.c_char), - ('block', ctypes.c_char), + ("lsb", ctypes.c_char), + ("msb", ctypes.c_char), + ("block", ctypes.c_char), ] _pack_ = True @@ -1558,13 +1571,14 @@ class v4l2_rds_data(ctypes.Structure): # Audio # + class v4l2_audio(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('capability', ctypes.c_uint32), - ('mode', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 2), + ("index", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("capability", ctypes.c_uint32), + ("mode", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2), ] @@ -1576,11 +1590,11 @@ class v4l2_audio(ctypes.Structure): class v4l2_audioout(ctypes.Structure): _fields_ = [ - ('index', ctypes.c_uint32), - ('name', ctypes.c_char * 32), - ('capability', ctypes.c_uint32), - ('mode', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 2), + ("index", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("capability", ctypes.c_uint32), + ("mode", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2), ] @@ -1591,16 +1605,16 @@ class v4l2_audioout(ctypes.Structure): V4L2_ENC_IDX_FRAME_I = 0 V4L2_ENC_IDX_FRAME_P = 1 V4L2_ENC_IDX_FRAME_B = 2 -V4L2_ENC_IDX_FRAME_MASK = 0xf +V4L2_ENC_IDX_FRAME_MASK = 0xF class v4l2_enc_idx_entry(ctypes.Structure): _fields_ = [ - ('offset', ctypes.c_uint64), - ('pts', ctypes.c_uint64), - ('length', ctypes.c_uint32), - ('flags', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 2), + ("offset", ctypes.c_uint64), + ("pts", ctypes.c_uint64), + ("length", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2), ] @@ -1609,10 +1623,10 @@ class v4l2_enc_idx_entry(ctypes.Structure): class v4l2_enc_idx(ctypes.Structure): _fields_ = [ - ('entries', ctypes.c_uint32), - ('entries_cap', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 4), - ('entry', v4l2_enc_idx_entry * V4L2_ENC_IDX_ENTRIES), + ("entries", ctypes.c_uint32), + ("entries_cap", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 4), + ("entry", v4l2_enc_idx_entry * V4L2_ENC_IDX_ENTRIES), ] @@ -1628,36 +1642,37 @@ class v4l2_encoder_cmd(ctypes.Structure): class _u(ctypes.Union): class _s(ctypes.Structure): _fields_ = [ - ('data', ctypes.c_uint32 * 8), + ("data", ctypes.c_uint32 * 8), ] _fields_ = [ - ('raw', _s), + ("raw", _s), ] _fields_ = [ - ('cmd', ctypes.c_uint32), - ('flags', ctypes.c_uint32), - ('_u', _u), + ("cmd", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("_u", _u), ] - _anonymous_ = ('_u',) + _anonymous_ = ("_u",) # # Data services (VBI) # + class v4l2_vbi_format(ctypes.Structure): _fields_ = [ - ('sampling_rate', ctypes.c_uint32), - ('offset', ctypes.c_uint32), - ('samples_per_line', ctypes.c_uint32), - ('sample_format', ctypes.c_uint32), - ('start', ctypes.c_int32 * 2), - ('count', ctypes.c_uint32 * 2), - ('flags', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 2), + ("sampling_rate", ctypes.c_uint32), + ("offset", ctypes.c_uint32), + ("samples_per_line", ctypes.c_uint32), + ("sample_format", ctypes.c_uint32), + ("start", ctypes.c_int32 * 2), + ("count", ctypes.c_uint32 * 2), + ("flags", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2), ] @@ -1667,10 +1682,10 @@ class v4l2_vbi_format(ctypes.Structure): class v4l2_sliced_vbi_format(ctypes.Structure): _fields_ = [ - ('service_set', ctypes.c_uint16), - ('service_lines', ctypes.c_uint16 * 2 * 24), - ('io_size', ctypes.c_uint32), - ('reserved', ctypes.c_uint32 * 2), + ("service_set", ctypes.c_uint16), + ("service_lines", ctypes.c_uint16 * 2 * 24), + ("io_size", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2), ] @@ -1679,26 +1694,25 @@ class v4l2_sliced_vbi_format(ctypes.Structure): V4L2_SLICED_CAPTION_525 = 0x1000 V4L2_SLICED_WSS_625 = 0x4000 V4L2_SLICED_VBI_525 = V4L2_SLICED_CAPTION_525 -V4L2_SLICED_VBI_625 = ( - V4L2_SLICED_TELETEXT_B | V4L2_SLICED_VPS | V4L2_SLICED_WSS_625) +V4L2_SLICED_VBI_625 = V4L2_SLICED_TELETEXT_B | V4L2_SLICED_VPS | V4L2_SLICED_WSS_625 class v4l2_sliced_vbi_cap(ctypes.Structure): _fields_ = [ - ('service_set', ctypes.c_uint16), - ('service_lines', ctypes.c_uint16 * 2 * 24), - ('type', v4l2_buf_type), - ('reserved', ctypes.c_uint32 * 3), + ("service_set", ctypes.c_uint16), + ("service_lines", ctypes.c_uint16 * 2 * 24), + ("type", v4l2_buf_type), + ("reserved", ctypes.c_uint32 * 3), ] class v4l2_sliced_vbi_data(ctypes.Structure): _fields_ = [ - ('id', ctypes.c_uint32), - ('field', ctypes.c_uint32), - ('line', ctypes.c_uint32), - ('reserved', ctypes.c_uint32), - ('data', ctypes.c_char * 48), + ("id", ctypes.c_uint32), + ("field", ctypes.c_uint32), + ("line", ctypes.c_uint32), + ("reserved", ctypes.c_uint32), + ("data", ctypes.c_char * 48), ] @@ -1715,8 +1729,8 @@ class v4l2_sliced_vbi_data(ctypes.Structure): class v4l2_mpeg_vbi_itv0_line(ctypes.Structure): _fields_ = [ - ('id', ctypes.c_char), - ('data', ctypes.c_char * 42), + ("id", ctypes.c_char), + ("data", ctypes.c_char * 42), ] _pack_ = True @@ -1724,8 +1738,8 @@ class v4l2_mpeg_vbi_itv0_line(ctypes.Structure): class v4l2_mpeg_vbi_itv0(ctypes.Structure): _fields_ = [ - ('linemask', ctypes.c_uint32 * 2), # how to define __le32 in ctypes? - ('line', v4l2_mpeg_vbi_itv0_line * 35), + ("linemask", ctypes.c_uint32 * 2), # how to define __le32 in ctypes? + ("line", v4l2_mpeg_vbi_itv0_line * 35), ] _pack_ = True @@ -1733,7 +1747,7 @@ class v4l2_mpeg_vbi_itv0(ctypes.Structure): class v4l2_mpeg_vbi_ITV0(ctypes.Structure): _fields_ = [ - ('line', v4l2_mpeg_vbi_itv0_line * 36), + ("line", v4l2_mpeg_vbi_itv0_line * 36), ] _pack_ = True @@ -1746,16 +1760,13 @@ class v4l2_mpeg_vbi_ITV0(ctypes.Structure): class v4l2_mpeg_vbi_fmt_ivtv(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('itv0', v4l2_mpeg_vbi_itv0), - ('ITV0', v4l2_mpeg_vbi_ITV0), + ("itv0", v4l2_mpeg_vbi_itv0), + ("ITV0", v4l2_mpeg_vbi_ITV0), ] - _fields_ = [ - ('magic', ctypes.c_char * 4), - ('_u', _u) - ] + _fields_ = [("magic", ctypes.c_char * 4), ("_u", _u)] - _anonymous_ = ('_u',) + _anonymous_ = ("_u",) _pack_ = True @@ -1763,34 +1774,32 @@ class _u(ctypes.Union): # Aggregate structures # + class v4l2_format(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('pix', v4l2_pix_format), - ('win', v4l2_window), - ('vbi', v4l2_vbi_format), - ('sliced', v4l2_sliced_vbi_format), - ('raw_data', ctypes.c_char * 200), + ("pix", v4l2_pix_format), + ("win", v4l2_window), + ("vbi", v4l2_vbi_format), + ("sliced", v4l2_sliced_vbi_format), + ("raw_data", ctypes.c_char * 200), ] _fields_ = [ - ('type', v4l2_buf_type), - ('fmt', _u), + ("type", v4l2_buf_type), + ("fmt", _u), ] class v4l2_streamparm(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('capture', v4l2_captureparm), - ('output', v4l2_outputparm), - ('raw_data', ctypes.c_char * 200), + ("capture", v4l2_captureparm), + ("output", v4l2_outputparm), + ("raw_data", ctypes.c_char * 200), ] - _fields_ = [ - ('type', v4l2_buf_type), - ('parm', _u) - ] + _fields_ = [("type", v4l2_buf_type), ("parm", _u)] # @@ -1806,25 +1815,25 @@ class _u(ctypes.Union): class v4l2_dbg_match(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ - ('addr', ctypes.c_uint32), - ('name', ctypes.c_char * 32), + ("addr", ctypes.c_uint32), + ("name", ctypes.c_char * 32), ] _fields_ = [ - ('type', ctypes.c_uint32), - ('_u', _u), + ("type", ctypes.c_uint32), + ("_u", _u), ] - _anonymous_ = ('_u',) + _anonymous_ = ("_u",) _pack_ = True class v4l2_dbg_register(ctypes.Structure): _fields_ = [ - ('match', v4l2_dbg_match), - ('size', ctypes.c_uint32), - ('reg', ctypes.c_uint64), - ('val', ctypes.c_uint64), + ("match", v4l2_dbg_match), + ("size", ctypes.c_uint32), + ("reg", ctypes.c_uint64), + ("val", ctypes.c_uint64), ] _pack_ = True @@ -1832,9 +1841,9 @@ class v4l2_dbg_register(ctypes.Structure): class v4l2_dbg_chip_ident(ctypes.Structure): _fields_ = [ - ('match', v4l2_dbg_match), - ('ident', ctypes.c_uint32), - ('revision', ctypes.c_uint32), + ("match", v4l2_dbg_match), + ("ident", ctypes.c_uint32), + ("revision", ctypes.c_uint32), ] _pack_ = True @@ -1844,87 +1853,87 @@ class v4l2_dbg_chip_ident(ctypes.Structure): # ioctl codes for video devices # -VIDIOC_QUERYCAP = _IOR('V', 0, v4l2_capability) -VIDIOC_RESERVED = _IO('V', 1) -VIDIOC_ENUM_FMT = _IOWR('V', 2, v4l2_fmtdesc) -VIDIOC_G_FMT = _IOWR('V', 4, v4l2_format) -VIDIOC_S_FMT = _IOWR('V', 5, v4l2_format) -VIDIOC_REQBUFS = _IOWR('V', 8, v4l2_requestbuffers) -VIDIOC_QUERYBUF = _IOWR('V', 9, v4l2_buffer) -VIDIOC_G_FBUF = _IOR('V', 10, v4l2_framebuffer) -VIDIOC_S_FBUF = _IOW('V', 11, v4l2_framebuffer) -VIDIOC_OVERLAY = _IOW('V', 14, ctypes.c_int) -VIDIOC_QBUF = _IOWR('V', 15, v4l2_buffer) -VIDIOC_DQBUF = _IOWR('V', 17, v4l2_buffer) -VIDIOC_STREAMON = _IOW('V', 18, ctypes.c_int) -VIDIOC_STREAMOFF = _IOW('V', 19, ctypes.c_int) -VIDIOC_G_PARM = _IOWR('V', 21, v4l2_streamparm) -VIDIOC_S_PARM = _IOWR('V', 22, v4l2_streamparm) -VIDIOC_G_STD = _IOR('V', 23, v4l2_std_id) -VIDIOC_S_STD = _IOW('V', 24, v4l2_std_id) -VIDIOC_ENUMSTD = _IOWR('V', 25, v4l2_standard) -VIDIOC_ENUMINPUT = _IOWR('V', 26, v4l2_input) -VIDIOC_G_CTRL = _IOWR('V', 27, v4l2_control) -VIDIOC_S_CTRL = _IOWR('V', 28, v4l2_control) -VIDIOC_G_TUNER = _IOWR('V', 29, v4l2_tuner) -VIDIOC_S_TUNER = _IOW('V', 30, v4l2_tuner) -VIDIOC_G_AUDIO = _IOR('V', 33, v4l2_audio) -VIDIOC_S_AUDIO = _IOW('V', 34, v4l2_audio) -VIDIOC_QUERYCTRL = _IOWR('V', 36, v4l2_queryctrl) -VIDIOC_QUERYMENU = _IOWR('V', 37, v4l2_querymenu) -VIDIOC_G_INPUT = _IOR('V', 38, ctypes.c_int) -VIDIOC_S_INPUT = _IOWR('V', 39, ctypes.c_int) -VIDIOC_G_OUTPUT = _IOR('V', 46, ctypes.c_int) -VIDIOC_S_OUTPUT = _IOWR('V', 47, ctypes.c_int) -VIDIOC_ENUMOUTPUT = _IOWR('V', 48, v4l2_output) -VIDIOC_G_AUDOUT = _IOR('V', 49, v4l2_audioout) -VIDIOC_S_AUDOUT = _IOW('V', 50, v4l2_audioout) -VIDIOC_G_MODULATOR = _IOWR('V', 54, v4l2_modulator) -VIDIOC_S_MODULATOR = _IOW('V', 55, v4l2_modulator) -VIDIOC_G_FREQUENCY = _IOWR('V', 56, v4l2_frequency) -VIDIOC_S_FREQUENCY = _IOW('V', 57, v4l2_frequency) -VIDIOC_CROPCAP = _IOWR('V', 58, v4l2_cropcap) -VIDIOC_G_CROP = _IOWR('V', 59, v4l2_crop) -VIDIOC_S_CROP = _IOW('V', 60, v4l2_crop) -VIDIOC_G_JPEGCOMP = _IOR('V', 61, v4l2_jpegcompression) -VIDIOC_S_JPEGCOMP = _IOW('V', 62, v4l2_jpegcompression) -VIDIOC_QUERYSTD = _IOR('V', 63, v4l2_std_id) -VIDIOC_TRY_FMT = _IOWR('V', 64, v4l2_format) -VIDIOC_ENUMAUDIO = _IOWR('V', 65, v4l2_audio) -VIDIOC_ENUMAUDOUT = _IOWR('V', 66, v4l2_audioout) -VIDIOC_G_PRIORITY = _IOR('V', 67, v4l2_priority) -VIDIOC_S_PRIORITY = _IOW('V', 68, v4l2_priority) -VIDIOC_G_SLICED_VBI_CAP = _IOWR('V', 69, v4l2_sliced_vbi_cap) -VIDIOC_LOG_STATUS = _IO('V', 70) -VIDIOC_G_EXT_CTRLS = _IOWR('V', 71, v4l2_ext_controls) -VIDIOC_S_EXT_CTRLS = _IOWR('V', 72, v4l2_ext_controls) -VIDIOC_TRY_EXT_CTRLS = _IOWR('V', 73, v4l2_ext_controls) - -VIDIOC_ENUM_FRAMESIZES = _IOWR('V', 74, v4l2_frmsizeenum) -VIDIOC_ENUM_FRAMEINTERVALS = _IOWR('V', 75, v4l2_frmivalenum) -VIDIOC_G_ENC_INDEX = _IOR('V', 76, v4l2_enc_idx) -VIDIOC_ENCODER_CMD = _IOWR('V', 77, v4l2_encoder_cmd) -VIDIOC_TRY_ENCODER_CMD = _IOWR('V', 78, v4l2_encoder_cmd) - -VIDIOC_DBG_S_REGISTER = _IOW('V', 79, v4l2_dbg_register) -VIDIOC_DBG_G_REGISTER = _IOWR('V', 80, v4l2_dbg_register) - -VIDIOC_DBG_G_CHIP_IDENT = _IOWR('V', 81, v4l2_dbg_chip_ident) - -VIDIOC_S_HW_FREQ_SEEK = _IOW('V', 82, v4l2_hw_freq_seek) -VIDIOC_ENUM_DV_PRESETS = _IOWR('V', 83, v4l2_dv_enum_preset) -VIDIOC_S_DV_PRESET = _IOWR('V', 84, v4l2_dv_preset) -VIDIOC_G_DV_PRESET = _IOWR('V', 85, v4l2_dv_preset) -VIDIOC_QUERY_DV_PRESET = _IOR('V', 86, v4l2_dv_preset) -VIDIOC_S_DV_TIMINGS = _IOWR('V', 87, v4l2_dv_timings) -VIDIOC_G_DV_TIMINGS = _IOWR('V', 88, v4l2_dv_timings) - -VIDIOC_OVERLAY_OLD = _IOWR('V', 14, ctypes.c_int) -VIDIOC_S_PARM_OLD = _IOW('V', 22, v4l2_streamparm) -VIDIOC_S_CTRL_OLD = _IOW('V', 28, v4l2_control) -VIDIOC_G_AUDIO_OLD = _IOWR('V', 33, v4l2_audio) -VIDIOC_G_AUDOUT_OLD = _IOWR('V', 49, v4l2_audioout) -VIDIOC_CROPCAP_OLD = _IOR('V', 58, v4l2_cropcap) +VIDIOC_QUERYCAP = _IOR("V", 0, v4l2_capability) +VIDIOC_RESERVED = _IO("V", 1) +VIDIOC_ENUM_FMT = _IOWR("V", 2, v4l2_fmtdesc) +VIDIOC_G_FMT = _IOWR("V", 4, v4l2_format) +VIDIOC_S_FMT = _IOWR("V", 5, v4l2_format) +VIDIOC_REQBUFS = _IOWR("V", 8, v4l2_requestbuffers) +VIDIOC_QUERYBUF = _IOWR("V", 9, v4l2_buffer) +VIDIOC_G_FBUF = _IOR("V", 10, v4l2_framebuffer) +VIDIOC_S_FBUF = _IOW("V", 11, v4l2_framebuffer) +VIDIOC_OVERLAY = _IOW("V", 14, ctypes.c_int) +VIDIOC_QBUF = _IOWR("V", 15, v4l2_buffer) +VIDIOC_DQBUF = _IOWR("V", 17, v4l2_buffer) +VIDIOC_STREAMON = _IOW("V", 18, ctypes.c_int) +VIDIOC_STREAMOFF = _IOW("V", 19, ctypes.c_int) +VIDIOC_G_PARM = _IOWR("V", 21, v4l2_streamparm) +VIDIOC_S_PARM = _IOWR("V", 22, v4l2_streamparm) +VIDIOC_G_STD = _IOR("V", 23, v4l2_std_id) +VIDIOC_S_STD = _IOW("V", 24, v4l2_std_id) +VIDIOC_ENUMSTD = _IOWR("V", 25, v4l2_standard) +VIDIOC_ENUMINPUT = _IOWR("V", 26, v4l2_input) +VIDIOC_G_CTRL = _IOWR("V", 27, v4l2_control) +VIDIOC_S_CTRL = _IOWR("V", 28, v4l2_control) +VIDIOC_G_TUNER = _IOWR("V", 29, v4l2_tuner) +VIDIOC_S_TUNER = _IOW("V", 30, v4l2_tuner) +VIDIOC_G_AUDIO = _IOR("V", 33, v4l2_audio) +VIDIOC_S_AUDIO = _IOW("V", 34, v4l2_audio) +VIDIOC_QUERYCTRL = _IOWR("V", 36, v4l2_queryctrl) +VIDIOC_QUERYMENU = _IOWR("V", 37, v4l2_querymenu) +VIDIOC_G_INPUT = _IOR("V", 38, ctypes.c_int) +VIDIOC_S_INPUT = _IOWR("V", 39, ctypes.c_int) +VIDIOC_G_OUTPUT = _IOR("V", 46, ctypes.c_int) +VIDIOC_S_OUTPUT = _IOWR("V", 47, ctypes.c_int) +VIDIOC_ENUMOUTPUT = _IOWR("V", 48, v4l2_output) +VIDIOC_G_AUDOUT = _IOR("V", 49, v4l2_audioout) +VIDIOC_S_AUDOUT = _IOW("V", 50, v4l2_audioout) +VIDIOC_G_MODULATOR = _IOWR("V", 54, v4l2_modulator) +VIDIOC_S_MODULATOR = _IOW("V", 55, v4l2_modulator) +VIDIOC_G_FREQUENCY = _IOWR("V", 56, v4l2_frequency) +VIDIOC_S_FREQUENCY = _IOW("V", 57, v4l2_frequency) +VIDIOC_CROPCAP = _IOWR("V", 58, v4l2_cropcap) +VIDIOC_G_CROP = _IOWR("V", 59, v4l2_crop) +VIDIOC_S_CROP = _IOW("V", 60, v4l2_crop) +VIDIOC_G_JPEGCOMP = _IOR("V", 61, v4l2_jpegcompression) +VIDIOC_S_JPEGCOMP = _IOW("V", 62, v4l2_jpegcompression) +VIDIOC_QUERYSTD = _IOR("V", 63, v4l2_std_id) +VIDIOC_TRY_FMT = _IOWR("V", 64, v4l2_format) +VIDIOC_ENUMAUDIO = _IOWR("V", 65, v4l2_audio) +VIDIOC_ENUMAUDOUT = _IOWR("V", 66, v4l2_audioout) +VIDIOC_G_PRIORITY = _IOR("V", 67, v4l2_priority) +VIDIOC_S_PRIORITY = _IOW("V", 68, v4l2_priority) +VIDIOC_G_SLICED_VBI_CAP = _IOWR("V", 69, v4l2_sliced_vbi_cap) +VIDIOC_LOG_STATUS = _IO("V", 70) +VIDIOC_G_EXT_CTRLS = _IOWR("V", 71, v4l2_ext_controls) +VIDIOC_S_EXT_CTRLS = _IOWR("V", 72, v4l2_ext_controls) +VIDIOC_TRY_EXT_CTRLS = _IOWR("V", 73, v4l2_ext_controls) + +VIDIOC_ENUM_FRAMESIZES = _IOWR("V", 74, v4l2_frmsizeenum) +VIDIOC_ENUM_FRAMEINTERVALS = _IOWR("V", 75, v4l2_frmivalenum) +VIDIOC_G_ENC_INDEX = _IOR("V", 76, v4l2_enc_idx) +VIDIOC_ENCODER_CMD = _IOWR("V", 77, v4l2_encoder_cmd) +VIDIOC_TRY_ENCODER_CMD = _IOWR("V", 78, v4l2_encoder_cmd) + +VIDIOC_DBG_S_REGISTER = _IOW("V", 79, v4l2_dbg_register) +VIDIOC_DBG_G_REGISTER = _IOWR("V", 80, v4l2_dbg_register) + +VIDIOC_DBG_G_CHIP_IDENT = _IOWR("V", 81, v4l2_dbg_chip_ident) + +VIDIOC_S_HW_FREQ_SEEK = _IOW("V", 82, v4l2_hw_freq_seek) +VIDIOC_ENUM_DV_PRESETS = _IOWR("V", 83, v4l2_dv_enum_preset) +VIDIOC_S_DV_PRESET = _IOWR("V", 84, v4l2_dv_preset) +VIDIOC_G_DV_PRESET = _IOWR("V", 85, v4l2_dv_preset) +VIDIOC_QUERY_DV_PRESET = _IOR("V", 86, v4l2_dv_preset) +VIDIOC_S_DV_TIMINGS = _IOWR("V", 87, v4l2_dv_timings) +VIDIOC_G_DV_TIMINGS = _IOWR("V", 88, v4l2_dv_timings) + +VIDIOC_OVERLAY_OLD = _IOWR("V", 14, ctypes.c_int) +VIDIOC_S_PARM_OLD = _IOW("V", 22, v4l2_streamparm) +VIDIOC_S_CTRL_OLD = _IOW("V", 28, v4l2_control) +VIDIOC_G_AUDIO_OLD = _IOWR("V", 33, v4l2_audio) +VIDIOC_G_AUDOUT_OLD = _IOWR("V", 49, v4l2_audioout) +VIDIOC_CROPCAP_OLD = _IOR("V", 58, v4l2_cropcap) BASE_VIDIOC_PRIVATE = 192 @@ -1934,14 +1943,14 @@ class v4l2_dbg_chip_ident(ctypes.Structure): class uvc_xu_control_query(ctypes.Structure): _fields_ = [ - ('driver', ctypes.c_char * 1), - ('selector', ctypes.c_char * 1), - ('query', ctypes.c_char * 1), - ('size', ctypes.c_uint16), - ('data', ctypes.c_char_p), + ("driver", ctypes.c_char * 1), + ("selector", ctypes.c_char * 1), + ("query", ctypes.c_char * 1), + ("size", ctypes.c_uint16), + ("data", ctypes.c_char_p), ] UVC_SET_CUR = 0x01 UVC_GET_CUR = 0x81 -UVCIOC_CTRL_QUERY = _IOWR('u', 0x21, uvc_xu_control_query) +UVCIOC_CTRL_QUERY = _IOWR("u", 0x21, uvc_xu_control_query) diff --git a/backend_py/src/services/cameras/xu_controls.py b/backend_py/src/services/cameras/xu_controls.py index ca6510af..5d338f3b 100644 --- a/backend_py/src/services/cameras/xu_controls.py +++ b/backend_py/src/services/cameras/xu_controls.py @@ -1,12 +1,12 @@ """ xu_controls.py -Specifies the constants of where each extension unit feature's register address is stored +Specifies the constants of where each extension unit feature's register address is +stored """ from enum import Enum - DWE_DEVICE_TAG = 0x9A @@ -49,6 +49,9 @@ class StellarRegisterMap(Enum): REG_MODE = 0x1677 REG_TRIG = 0x1678 REG_STROBE_ENABLED = 0x8100 + REG_HW_BITRATE_HIGH = 0x2D6 + REG_HW_BITRATE_LOW = 0x2D7 + REG_HW_BITRATE_TRIG = 0x2DB class StellarSensorMap: diff --git a/backend_py/src/services/lights/__init__.py b/backend_py/src/services/lights/__init__.py index 936a6fb2..8238e2fc 100644 --- a/backend_py/src/services/lights/__init__.py +++ b/backend_py/src/services/lights/__init__.py @@ -1,7 +1,11 @@ -from .fake_pwm import * -from .light_manager import * -from .light_types import * -from .light import * -from .pwm_controller import * -from .rpi_pwm_hardware import * -from .utils import * \ No newline at end of file +from .light import DisableLightInfo, Light, SetLightInfo +from .light_manager import LightManager +from .utils import create_pwm_controllers + +__all__ = [ + "LightManager", + "create_pwm_controllers", + "DisableLightInfo", + "Light", + "SetLightInfo", +] diff --git a/backend_py/src/services/lights/fake_pwm.py b/backend_py/src/services/lights/fake_pwm.py index 19e15445..6dff4108 100644 --- a/backend_py/src/services/lights/fake_pwm.py +++ b/backend_py/src/services/lights/fake_pwm.py @@ -1,25 +1,24 @@ from .pwm_controller import PWMController -from typing import Dict -import logging + class FakePWMController(PWMController): - NAME = 'Fake PWM Controller' + NAME = "Fake PWM Controller" def __init__(self) -> None: super().__init__() def is_pwm_pin(self, pin: int) -> bool: return True - - def set_intensity(self, pin: int, intensity: float): + + def set_intensity(self, pin: int, intensity: float) -> None: # logging.log(f'{}') pass - def cleanup(self): + def cleanup(self) -> None: pass - def disable_pin(self, pin: int): + def disable_pin(self, pin: int) -> None: pass - def get_pins(self): + def get_pins(self) -> list[int]: return [1, 2, 3, 4] diff --git a/backend_py/src/services/lights/light.py b/backend_py/src/services/lights/light.py index ac0d2f6d..288b3e50 100644 --- a/backend_py/src/services/lights/light.py +++ b/backend_py/src/services/lights/light.py @@ -7,6 +7,7 @@ from pydantic import BaseModel + class Light(BaseModel): intensity: float pin: int @@ -14,10 +15,12 @@ class Light(BaseModel): controller_index: int controller_name: str + class SetLightInfo(BaseModel): index: int intensity: float + class DisableLightInfo(BaseModel): controller_index: int pin: int diff --git a/backend_py/src/services/lights/light_manager.py b/backend_py/src/services/lights/light_manager.py index d3a684fe..157491f4 100644 --- a/backend_py/src/services/lights/light_manager.py +++ b/backend_py/src/services/lights/light_manager.py @@ -1,22 +1,23 @@ """ light_manager.py -Manages the light system, initiates the proper PWM controllers and creates Light objects for each available pin +Manages the light system, initiates the proper PWM controllers and creates Light objects +for each available pin. + Serves as the main interface for setting light intensity or disbaling lights Calls on PWM controllers to do the actual PWM """ -from typing import List, Dict -from .pwm_controller import PWMController -from .light import Light import logging +from .light import Light +from .pwm_controller import PWMController -class LightManager: - def __init__(self, pwm_controllers: List[PWMController]) -> None: +class LightManager: + def __init__(self, pwm_controllers: list[PWMController]) -> None: self.pwm_controllers = pwm_controllers - self.lights: List[Light] = [] + self.lights: list[Light] = [] self.logger = logging.getLogger("dwe_os_2.LightManager") for controller_index in range(len(self.pwm_controllers)): controller = self.pwm_controllers[controller_index] @@ -31,7 +32,7 @@ def __init__(self, pwm_controllers: List[PWMController]) -> None: ) ) - def set_intensity(self, index: int, intensity: float): + def set_intensity(self, index: int, intensity: float) -> None: light = self.lights[index] light.intensity = intensity pwm_controller = self.pwm_controllers[light.controller_index] @@ -40,7 +41,7 @@ def set_intensity(self, index: int, intensity: float): ) pwm_controller.set_intensity(light.pin, intensity) - def disable_light(self, controller_index: int, pin: int): + def disable_light(self, controller_index: int, pin: int) -> None: if controller_index >= len(self.pwm_controllers): self.logger.error("Invalid index given for pwm controller") return @@ -49,9 +50,9 @@ def disable_light(self, controller_index: int, pin: int): self.logger.info(f"Disabling light ({pwm_controller.NAME}): {pin}") pwm_controller.disable_pin(pin) - def get_lights(self): + def get_lights(self) -> list[Light]: return self.lights - def cleanup(self): + def cleanup(self) -> None: for pwm_controller in self.pwm_controllers: pwm_controller.cleanup() diff --git a/backend_py/src/services/lights/light_types.py b/backend_py/src/services/lights/light_types.py index 330412fb..e69de29b 100644 --- a/backend_py/src/services/lights/light_types.py +++ b/backend_py/src/services/lights/light_types.py @@ -1,2 +0,0 @@ -from enum import Enum -from dataclasses import dataclass diff --git a/backend_py/src/services/lights/pwm_controller.py b/backend_py/src/services/lights/pwm_controller.py index 74547347..3621607d 100644 --- a/backend_py/src/services/lights/pwm_controller.py +++ b/backend_py/src/services/lights/pwm_controller.py @@ -5,22 +5,22 @@ Maintains consistency with PWM functionality """ -from abc import ABC, abstractmethod import logging +from abc import ABC, abstractmethod class PWMController(ABC): NAME = "Abstract Controller" - def __init__(self): + def __init__(self) -> None: self.logger = logging.getLogger("dwe_os_2.PWMController") @abstractmethod - def set_intensity(self, pin: int, intensity: float): + def set_intensity(self, pin: int, intensity: float) -> None: self.logger.info(f"Setting light intensity: {pin} to {intensity}") @abstractmethod - def disable_pin(self, pin: int): + def disable_pin(self, pin: int) -> None: pass @abstractmethod @@ -28,9 +28,9 @@ def is_pwm_pin(self, pin: int) -> bool: pass @abstractmethod - def cleanup(self): + def cleanup(self) -> None: pass @abstractmethod - def get_pins(self): + def get_pins(self) -> list[int]: return [] diff --git a/backend_py/src/services/lights/pwm_manager.py b/backend_py/src/services/lights/pwm_manager.py index e1ad46a2..e61230bd 100644 --- a/backend_py/src/services/lights/pwm_manager.py +++ b/backend_py/src/services/lights/pwm_manager.py @@ -1,8 +1,8 @@ +import logging import os -from dataclasses import dataclass -from typing import List import re -import time +from dataclasses import dataclass + @dataclass class PWMChannel: @@ -10,29 +10,44 @@ class PWMChannel: frequency: float = 0 duty_cycle: float = 0 + @dataclass class PWMChip: chip: int - channels: List[PWMChannel] + channels: list[PWMChannel] + class PWMManager: - PWM_BASE_PATH = '/sys/class/pwm' + PWM_BASE_PATH = "/sys/class/pwm" CHIP_REGEX = re.compile(r"pwmchip(\d+)") CHANNEL_REGEX = re.compile(r"pwm(\d+)") def __init__(self) -> None: - self.chips: List[PWMChip] = [] + self.chips: list[PWMChip] = [] self._enumerate() - def enable_channel(self, chip_id: int, channel_id: int): - self._echo(os.path.join(self._get_channel_path(chip_id, channel_id), 'enable'), 1) + self.logger = logging.getLogger("dwe_os_2.services.PWMManager") + + def enable_channel(self, chip_id: int, channel_id: int) -> None: + self._echo( + os.path.join(self._get_channel_path(chip_id, channel_id), "enable"), 1 + ) - def disable_channel(self, chip_id: int, channel_id: int): - self._echo(os.path.join(self._get_channel_path(chip_id, channel_id), 'enable'), 0) + def disable_channel(self, chip_id: int, channel_id: int) -> None: + self._echo( + os.path.join(self._get_channel_path(chip_id, channel_id), "enable"), 0 + ) - def set_channel_frequency(self, chip_id: int, channel_id: int, frequency: float): + def set_channel_frequency( + self, chip_id: int, channel_id: int, frequency: float + ) -> None: channel = self._get_channel(chip_id, channel_id) + + if not channel: + self.logger.error(f"Failed to get channel: {chip_id}:{channel_id}") + return + channel.frequency = frequency # Save the current duty cycle and zero it @@ -46,52 +61,71 @@ def set_channel_frequency(self, chip_id: int, channel_id: int, frequency: float) # Restore the original duty cycle self._set_duty_cycle(chip_id, channel_id, original_duty_cycle, frequency) - def set_channel_duty_cycle(self, chip_id: int, channel_id: int, duty_cycle: float): + def set_channel_duty_cycle( + self, chip_id: int, channel_id: int, duty_cycle: float + ) -> None: channel = self._get_channel(chip_id, channel_id) + + if not channel: + self.logger.error(f"Failed to get channel: {chip_id}:{channel_id}") + return + self._set_duty_cycle(chip_id, channel_id, duty_cycle, channel.frequency) channel.duty_cycle = duty_cycle - def _set_channel_frequency(self, chip_id: int, channel_id: int, frequency: float): + def _set_channel_frequency( + self, chip_id: int, channel_id: int, frequency: float + ) -> None: period_ns = int((1 / frequency) * 1_000_000_000) - period_path = os.path.join(self._get_channel_path(chip_id, channel_id), 'period') + period_path = os.path.join( + self._get_channel_path(chip_id, channel_id), "period" + ) self._echo(period_path, period_ns) - def _set_duty_cycle(self, chip_id: int, channel_id: int, duty_cycle: float, frequency: float): + def _set_duty_cycle( + self, chip_id: int, channel_id: int, duty_cycle: float, frequency: float + ) -> None: # Compute duty cycle in nanoseconds period_ns = int((1 / frequency) * 1_000_000_000) duty_cycle_ns = int((duty_cycle / 100) * period_ns) - duty_cycle_path = os.path.join(self._get_channel_path(chip_id, channel_id), 'duty_cycle') + duty_cycle_path = os.path.join( + self._get_channel_path(chip_id, channel_id), "duty_cycle" + ) # Write the duty cycle value self._echo(duty_cycle_path, duty_cycle_ns) def _get_chip_path(self, chip_id: int) -> str: - return os.path.join(self.PWM_BASE_PATH, f'pwmchip{chip_id}') - + return os.path.join(self.PWM_BASE_PATH, f"pwmchip{chip_id}") + def _get_channel_path(self, chip_id: int, channel_id: int) -> str: - return os.path.join(self._get_chip_path(chip_id), f'pwm{channel_id}') + return os.path.join(self._get_chip_path(chip_id), f"pwm{channel_id}") - def _get_channel(self, chip_id: int, channel_id: int): + def _get_channel(self, chip_id: int, channel_id: int) -> PWMChannel | None: chip = self._get_chip(chip_id) + if not chip: + self.logger.error(f"Failed to get chip: {chip_id}:{channel_id}") + return None + for channel in chip.channels: if channel.channel == channel_id: return channel - + return None - def _get_chip(self, chip_id: int): + def _get_chip(self, chip_id: int) -> PWMChip | None: for chip in self.chips: if chip.chip == chip_id: return chip return None - def _echo(self, file: str, value: int): - with open(file, 'w') as export_file: + def _echo(self, file: str, value: int) -> None: + with open(file, "w") as export_file: export_file.write(str(value)) - - def _enumerate(self): + + def _enumerate(self) -> None: for chip_entry in os.listdir(self.PWM_BASE_PATH): # Get the match of the chip chip_match = self.CHIP_REGEX.match(chip_entry) @@ -100,23 +134,23 @@ def _enumerate(self): chip_number = int(chip_match.group(1)) chip_path = os.path.join(self.PWM_BASE_PATH, chip_entry) - npwm_path = os.path.join(chip_path, 'npwm') - with open(npwm_path, 'r') as f: + npwm_path = os.path.join(chip_path, "npwm") + with open(npwm_path) as f: npwm = int(f.read().strip()) - channels: List[PWMChannel] = [] + channels: list[PWMChannel] = [] # Iterate over every channel - export_path = os.path.join(chip_path, 'export') + export_path = os.path.join(chip_path, "export") for channel_number in range(npwm): - pwm_channel_path = os.path.join(chip_path, f'pwm{channel_number}') + pwm_channel_path = os.path.join(chip_path, f"pwm{channel_number}") if not os.path.exists(pwm_channel_path): # create the export try: self._echo(export_path, channel_number) except OSError: continue - + channels.append(PWMChannel(channel_number)) self.chips.append(PWMChip(chip=chip_number, channels=channels)) diff --git a/backend_py/src/services/lights/rpi_pwm_hardware.py b/backend_py/src/services/lights/rpi_pwm_hardware.py index 4c195123..8487dae2 100644 --- a/backend_py/src/services/lights/rpi_pwm_hardware.py +++ b/backend_py/src/services/lights/rpi_pwm_hardware.py @@ -1,15 +1,17 @@ """ rpi_pwm_hardware.py -Talks to the Raspberry Pi's processor to set light intensity using Pulse Width Modulation +Talks to the Raspberry Pi's processor to set light intensity using PWM Determines pins the lights are connected to as well as if they support pwm -Raspberry Pi generates a square wave at set intensity (50% = square wave where 50% is on, 50% is off) +Raspberry Pi generates a square wave at set intensity +(50% = square wave where 50% is on, 50% is off) + +This is from """ from rpi_hardware_pwm import HardwarePWM, HardwarePWMException + from .pwm_controller import PWMController -from typing import Dict -import logging class RPiHardwarePWMController(PWMController): @@ -27,16 +29,17 @@ def __init__(self, chip=0, pins=None) -> None: pins = {18: 0, 19: 1} self.PWM_PINS = pins - self.pwm_objects: Dict[int, HardwarePWM] = {} + self.pwm_objects: dict[int, HardwarePWM] = {} - for pin in self.PWM_PINS.keys(): + for pin in self.PWM_PINS: try: self.pwm_objects[pin] = HardwarePWM( pwm_channel=self.PWM_PINS[pin], hz=self.PWM_FREQUENCY, chip=chip ) except HardwarePWMException: self.logger.warning( - "Hardware PWM is not enabled. Please add 'dtoverlay=pwm-2chan' to /boot/firmware/config.txt and reboot." + "Hardware PWM is not enabled. Please add 'dtoverlay=pwm-2chan' to " + "/boot/firmware/config.txt and reboot." ) self.pwm_supported = False break @@ -52,16 +55,17 @@ def __init__(self, chip=0, pins=None) -> None: def is_pwm_pin(self, pin: int) -> bool: """ - Return true if the pin is supported but will always return false if pwm is not supported entirely + Return true if the pin is supported but will always return false + if pwm is not entirely supported """ - return pin in self.PWM_PINS.keys() if self.pwm_supported else False + return pin in self.PWM_PINS if self.pwm_supported else False - def disable_pin(self, pin: int): + def disable_pin(self, pin: int) -> None: # FIXME: Not implemented # Planned to be implemented soon pass - def set_intensity(self, pin: int, intensity: float): + def set_intensity(self, pin: int, intensity: float) -> None: if not self.is_pwm_pin(pin): self.logger.warning( f"Attempted to use pin: {pin}, which is not supported by this device" @@ -71,9 +75,9 @@ def set_intensity(self, pin: int, intensity: float): duty_cycle = max(0, min(100, intensity)) self.pwm_objects[pin].change_duty_cycle(duty_cycle) - def cleanup(self): + def cleanup(self) -> None: for pwm in self.pwm_objects.values(): pwm.stop() - def get_pins(self): - return self.PWM_PINS.keys() if self.pwm_supported else [] + def get_pins(self) -> list[int]: + return list(self.PWM_PINS.keys()) if self.pwm_supported else [] diff --git a/backend_py/src/services/lights/utils.py b/backend_py/src/services/lights/utils.py index 4e1d7391..b0e1513c 100644 --- a/backend_py/src/services/lights/utils.py +++ b/backend_py/src/services/lights/utils.py @@ -1,17 +1,19 @@ """ utils.py -Determines what kind of system/hardware the application is on, and then tries to enable PWM controllers on them +Determines what kind of system/hardware the application is on, and then tries to +enable PWM controllers on them """ import logging - -# from .fake_pwm import FakePWMController -import re import os +import re + +from .pwm_controller import PWMController +from .rpi_pwm_hardware import RPiHardwarePWMController -def is_overlay_loaded(): +def is_overlay_loaded() -> bool: """ Based on function from rpi_hardware_pwm """ @@ -19,10 +21,10 @@ def is_overlay_loaded(): return os.path.isdir(chippath) -def get_rpi_version(): +def get_rpi_version() -> int | None: try: # Read the device model from the file - with open("/sys/firmware/devicetree/base/model", "r") as f: + with open("/sys/firmware/devicetree/base/model") as f: model = f.read().strip() # Check if the device is a Raspberry Pi @@ -44,18 +46,18 @@ def get_rpi_version(): return None -def create_pwm_controllers(): - pwm_controllers = [] +def create_pwm_controllers() -> list[PWMController]: + pwm_controllers: list[PWMController] = [] version = get_rpi_version() logger = logging.getLogger("dwe_os_2.pwm") if version is not None: logger.info(f"Device is Raspberry Pi {version}") if not is_overlay_loaded(): logger.warning( - "PWM Overlay not loaded. Need to add 'dtoverlay=pwm-2chan' to /boot/config.txt and reboot" + "PWM Overlay not loaded. Need to add 'dtoverlay=pwm-2chan' to " + "/boot/config.txt and reboot" ) return [] - from .rpi_pwm_hardware import RPiHardwarePWMController if version == 5: pwm_controllers.append( diff --git a/backend_py/src/services/network/__init__.py b/backend_py/src/services/network/__init__.py new file mode 100644 index 00000000..a830f2f6 --- /dev/null +++ b/backend_py/src/services/network/__init__.py @@ -0,0 +1,15 @@ +from .nm_wrapper import ( + ConnectionProfileModel, + IPV4Configuration, + IPV4Method, + NetworkWrapper, + WiredDeviceModel, +) + +__all__ = [ + "NetworkWrapper", + "WiredDeviceModel", + "ConnectionProfileModel", + "IPV4Configuration", + "IPV4Method", +] diff --git a/backend_py/src/services/network/async_network_manager.py b/backend_py/src/services/network/async_network_manager.py new file mode 100644 index 00000000..dd1cd7a4 --- /dev/null +++ b/backend_py/src/services/network/async_network_manager.py @@ -0,0 +1,591 @@ +import asyncio +import logging +import socket +import struct +from enum import Enum +from typing import Any + +import sdbus +from event_emitter import EventEmitter +from pydantic import BaseModel +from sdbus.utils.inspect import inspect_dbus_path +from sdbus_async.networkmanager import ( + ActiveConnection, + DeviceState, + DeviceType, + IPv4Config, + NetworkConnectionSettings, + NetworkDeviceGeneric, + NetworkDeviceWired, + NetworkManager, + NetworkManagerConnectionProperties, + NetworkManagerSetting, + NetworkManagerSettings, +) +from sdbus_async.networkmanager import ( + DeviceCapabilities as Capabilities, +) + + +class IPV4Method(Enum): + manual = "manual" + auto = "auto" + unknown = "unknown" + + +class IPV4Address(BaseModel): + address: str + prefix: int + + +class IPV4Configuration(BaseModel): + ip_addresses: list[IPV4Address] | None = None + gateway: str | None = None + method: IPV4Method = IPV4Method.unknown + dns: list[str] | None = None + never_default: bool | None = None + + +# ip to integer and reverse: https://stackoverflow.com/a/13294427 + + +def _ip_to_integer(addr: str) -> int: + return struct.unpack("!I", socket.inet_aton(addr))[0] + + +def _integer_to_ip(addr: int) -> str: + return socket.inet_ntoa(struct.pack("!I", addr)) + + +def _unpack_dbus_value(setting: NetworkManagerSetting | Any, expected_type="") -> Any: + """ + Recursively unpacks dbus values from a NetworkManagerSetting + """ + value = setting + if isinstance(value, tuple): + if len(value) == 2 and isinstance(value[0], str): + (actual_type, value) = setting + + if expected_type != "" and actual_type != expected_type: + raise AssertionError("Setting type does not match!") + else: + return tuple(_unpack_dbus_value(item) for item in value) + + if isinstance(value, list): + return [_unpack_dbus_value(item) for item in value] + + if isinstance(value, dict): + return {k: _unpack_dbus_value(v) for k, v in value.items()} + + return value + + +def _deserialize_ipv4_config(ipv4_settings: dict) -> IPV4Configuration: + """ + Get the serialized settings from the active nmconnection profile. + + If no profile is active, `None` is returned + """ + method = ipv4_settings.get("method", "unknown") + raw_addresses = _unpack_dbus_value( + ipv4_settings.get("address-data", ("aa{sv}", [])), "aa{sv}" + ) + dns_servers = _unpack_dbus_value(ipv4_settings.get("dns", ("au", [])), "au") + gateway = _unpack_dbus_value(ipv4_settings.get("gateway", ("s", "")), "s") + never_default = _unpack_dbus_value( + ipv4_settings.get("never-default", ("b", None)), "b" + ) + + ip_addresses = [ + IPV4Address(address=addr["address"], prefix=addr["prefix"]) + for addr in raw_addresses + ] + + ip_v4_config = IPV4Configuration( + ip_addresses=ip_addresses, + gateway=gateway, + method=IPV4Method(method), + dns=[_integer_to_ip(dns) for dns in dns_servers], + never_default=never_default, + ) + + return ip_v4_config + + +def _serialize_ipv4_config( + ipv4_configuration: IPV4Configuration, +) -> dict: + serialized_ip_config: dict = { + "method": ( + "s", + "auto" if ipv4_configuration.method == IPV4Method.auto else "manual", + ) + } + + if ( + ipv4_configuration.method == IPV4Method.manual + and ipv4_configuration.ip_addresses + ): + addr_data = [] + for addr in ipv4_configuration.ip_addresses: + addr_data.append( + {"address": ("s", addr.address), "prefix": ("u", addr.prefix)} + ) + + serialized_ip_config["address-data"] = ("aa{sv}", addr_data) + + if ipv4_configuration.gateway: + serialized_ip_config["gateway"] = ("s", ipv4_configuration.gateway) + + if ipv4_configuration is not None: + serialized_ip_config["never-default"] = ( + "b", + ipv4_configuration.never_default, + ) + + if ipv4_configuration.dns: + serialized_ip_config["dns"] = ( + "au", + [_ip_to_integer(dns) for dns in ipv4_configuration.dns], + ) + + return serialized_ip_config + + +class ConnectionProfile(EventEmitter): + def __init__(self, dbus_path: str) -> None: + super().__init__() + + self.logger = logging.getLogger("dwe_os_2.network.ConnectionProfile") + + self.settings: NetworkConnectionSettings = NetworkConnectionSettings(dbus_path) + self.dbus_path = dbus_path + + # https://networkmanager.dev/docs/api/latest/nm-settings-nmcli.html + self.settings_dict = {} + + # deserialized params + self.id: str | None = None + + self.ipv4_settings: IPV4Configuration | None = None + + self._on_update_task: asyncio.Task = asyncio.create_task( + self._on_update_listener() + ) + + def delete(self) -> None: + if self._on_update_task: + self._on_update_task.cancel() + + async def _update_configuration( + self, new_configuration: NetworkManagerConnectionProperties + ) -> None: + await self.settings.update(new_configuration) + + async def update_ipv4_configuration( + self, new_configuration: IPV4Configuration + ) -> None: + old_settings: dict = await self.settings.get_settings() + old_settings["ipv4"] = _serialize_ipv4_config(new_configuration) + await self.settings.update_unsaved(old_settings) + + async def save(self) -> None: + await self.settings.save() + + async def initialize(self) -> None: + await self._update_settings() + + async def _on_update_listener(self) -> None: + async for _ in self.settings.updated.catch(): + if self.settings: + self.logger.debug( + f"Updating connection settings for {await self.settings.filename}" + ) + await self._update_settings() + + async def _update_settings(self) -> None: + self.emit("settings_updated") + + if not self.settings: + self.logger.warning( + "Cannot update connection settings when there is no active connection" + ) + return + + self.settings_dict = _unpack_dbus_value(await self.settings.get_settings()) + + self.ipv4_settings = _deserialize_ipv4_config( + self.settings_dict.get("ipv4", {}) + ) + + self.id = self.settings_dict["connection"]["id"] + + +class WiredDevice(EventEmitter): + """ + Represents a NetworkManager wired device + """ + + def __init__(self, device_path: str) -> None: + super().__init__() + + self.logger = logging.getLogger("dwe_os_2.network.WiredDevice") + + self.nm_device = NetworkDeviceWired(device_path) + self.interface: str | None = None + + # Live state + self.state: DeviceState = DeviceState.UNKNOWN + # `has_active_connection` being True does not mean it has an active ip + # configuration yet. It takes some time after between IP_CONFIG and ACTIVATED + # The only way to verify this will be valid is either checking if it's None or + # checking if the state == ACTIVATED + self.active_ip_configuration: IPV4Configuration | None = None + + # Settings + self.active_profile_path = "/" + self.connection_profile_path = "/" + self.active_connection: ActiveConnection | None = None + self.connection_id = "" + self.has_active_connection = False + + self._settings_listener_task = None + self.tasks = [] + + self.manual_autoconnect = False + + async def initialize(self) -> None: + # Initialized here + self.interface = await self.nm_device.interface + + # Set the initial state + # (dbus returns uint32; coerce to DeviceState like _listen does) + await self._set_state(None, DeviceState(await self.nm_device.state)) + + # Add the ip configuration listener task + self.tasks.append(asyncio.create_task(self._listen())) + + def get_active_settings(self) -> IPV4Configuration | None: + if self.state != DeviceState.ACTIVATED or not self.active_ip_configuration: + return None + return self.active_ip_configuration + + def get_dbus_path(self) -> str: + return inspect_dbus_path(self.nm_device) + + def is_available(self) -> bool: + return self.state in [DeviceState.DISCONNECTED, DeviceState.ACTIVATED] + + async def _update_ipv4_connection_profile(self) -> None: + """ + Determine if the device is still active. + If so, start/continue utilizing the active connection. If not, delete it. + """ + + # Path to the actual connection object + # org.freedesktop.NetworkManager.Connection.Active + active_connection_path = await self.nm_device.active_connection + + if active_connection_path == "/": + if self.has_active_connection: + self.logger.info(f"{self.interface}: Lost active connection profile") + self.has_active_connection = False + self.connection_profile_path = "/" + self.active_profile_path = "/" + return + + if not self.has_active_connection: + self.logger.info(f"{self.interface}: Gained active connection profile") + self.has_active_connection = True + + self.active_profile_path = active_connection_path + + self.connection_profile_path = await ActiveConnection( + active_connection_path + ).connection + + async def _update_active_connection_settings(self) -> None: + if self.state != DeviceState.ACTIVATED: + self.logger.warning( + f"{self.interface}: Cannot update IP config of an inactive device" + ) + self.active_ip_configuration = None + return + + config_path = await self.nm_device.ip4_config + + if config_path == "/": + self.logger.error( + f"{self.interface}: Unable to retrieve IP config despite being active" + ) + return + + config = IPv4Config(config_path) + + # Initial construction + self.active_ip_configuration = IPV4Configuration() + + # Update the active data + address_data = _unpack_dbus_value(await config.address_data) + + self.active_ip_configuration.ip_addresses = [ + IPV4Address(address=addr["address"], prefix=addr["prefix"]) + for addr in address_data + ] + self.active_ip_configuration.gateway = await config.gateway + # Maybe we can do a single unpack + self.active_ip_configuration.dns = [ + data["address"] for data in _unpack_dbus_value(await config.nameserver_data) + ] + + self.emit("ip_config_changed") + + async def _set_state( + self, old_state: DeviceState | None, new_state: DeviceState + ) -> None: + """ + Update the device state + """ + self.state = new_state + + # Yes, we can decouple this into two methods, and remove the checking + # if there is a connection logic, but this is 100% guaranteed to be reliable + # and there is no tangible performance benefit for the former. + # We update the profile earlier to ensure there will never be a time when the + # active ip configuration is available, while the connection settings are not + if self.state in [ + DeviceState.ACTIVATED, + DeviceState.DEACTIVATING, + DeviceState.DISCONNECTED, + DeviceState.UNAVAILABLE, + DeviceState.IP_CONFIG, + ]: + await self._update_ipv4_connection_profile() + + if self.state == DeviceState.ACTIVATED: + # Update active data + await self._update_active_connection_settings() + else: + self.active_ip_configuration = None + + if ( + self.manual_autoconnect + and self.state == DeviceState.DISCONNECTED + and old_state == DeviceState.UNAVAILABLE + ): + self.emit("request_activation", self) + + self.emit("state_changed", old_state, self.state) + + async def _listen(self) -> None: + async for ( + new_state, + old_state, + _reason, + ) in self.nm_device.state_changed.catch(): + self.logger.info( + f"{self.interface}: " + f"Now {DeviceState(new_state).name}, " + f"was {DeviceState(old_state).name}" + ) + await self._set_state(DeviceState(old_state), DeviceState(new_state)) + + +class AsyncNetworkManager(EventEmitter): + def __init__(self) -> None: + super().__init__() + + self.logger = logging.getLogger("dwe_os_2.network.AsyncNetworkManager") + + # Get the system bus + self.bus = sdbus.sd_bus_open_system() + sdbus.set_default_bus(self.bus) + self.nm = NetworkManager() + self.nm_settings = NetworkManagerSettings() + + self.ethernet_devices: list[WiredDevice] = [] + + # dbus path: ConnectionProfile + self.profiles: dict[str, ConnectionProfile] = {} + + self._profiles_updated_task: asyncio.Task | None = None + + async def _update_profiles(self) -> None: + all_paths = await self.nm_settings.connections + self.profiles = {} + for path in all_paths: + profile = ConnectionProfile(path) + profile.on( + "settings_changed", + lambda profile=profile: self.emit("profile_updated", profile), + ) + await profile.initialize() + self.profiles[path] = profile + + def get_device_by_iface(self, iface_name: str) -> WiredDevice | None: + """ + Get a device by an interface name (e.g. eth0) + """ + for device in self.ethernet_devices: + if device.interface == iface_name: + return device + return None + + def get_compatible_profiles( + self, wired_device: WiredDevice + ) -> list[ConnectionProfile]: + """ + Get a list of compatible profiles for a given wired device + """ + compatible_profiles = [] + + for profile in self.profiles.values(): + settings = profile.settings_dict + + connection_settings = settings.get("connection", {}) + conn_type = connection_settings.get("type", "") + if conn_type != "802-3-ethernet": + continue + + # Ensure it's not a locked down connection + interface_name = connection_settings.get("interface-name", None) + if interface_name is not None and interface_name != wired_device.interface: + continue + + # TODO: mac filtering + + timestamp = connection_settings.get("timestamp", 0) + + compatible_profiles.append({"profile": profile, "timestamp": timestamp}) + + compatible_profiles.sort(key=lambda x: x["timestamp"], reverse=True) + + return [p["profile"] for p in compatible_profiles] + + def get_profile(self, path: str) -> ConnectionProfile | None: + """ + Get a connection profile by its dbus path. + This is a nondeterministic value and is only used for indexing live profiles + """ + return self.profiles.get(path, None) + + def get_profile_by_id(self, id: str) -> ConnectionProfile | None: + """ + Get a connection profile by id (e.g. Wired connection 1) + """ + for profile in self.profiles.values(): + if profile.id == id: + return profile + return None + + async def _get_best_connection( + self, wired_device: WiredDevice + ) -> ConnectionProfile | None: + profiles = self.get_compatible_profiles(wired_device) + return profiles[0] if len(profiles) > 0 else None + + async def activate_ethernet_device_by_index( + self, index: int, profile: ConnectionProfile | None = None + ) -> None: + if index >= len(self.ethernet_devices): + raise IndexError("Device index out of range") + + target_device = self.ethernet_devices[index] + await self.activate_ethernet_device(target_device, profile) + + async def activate_ethernet_device( + self, target_device: WiredDevice, profile: ConnectionProfile | None = None + ) -> None: + if not target_device.is_available(): + self.logger.error( + f"Device {target_device.interface} cannot be activated " + f"(state: {target_device.state.name})" + ) + return + + if profile is None: + profile = await self._get_best_connection(target_device) + if not profile: + self.logger.error( + f"Device {target_device.interface} has no available profile" + ) + return + + self.logger.info( + f"Activating device '{target_device.interface}' with profile '{profile.id}'" + ) + await self.nm.activate_connection( + profile.dbus_path, target_device.get_dbus_path() + ) + + async def get_first_active_device(self) -> WiredDevice | None: + for device in self.ethernet_devices: + if await device.nm_device.active_connection != "/": + return device + return None + + async def _listen_connection_profiles(self) -> None: + new_conn_iter = self.nm_settings.new_connection.catch() + rem_conn_iter = self.nm_settings.connection_removed.catch() + + async def handle_new() -> None: + async for path in new_conn_iter: + self.logger.info(f"New connection profile detected at {path}") + + new_profile = ConnectionProfile(path) + await new_profile._update_settings() + self.profiles[path] = new_profile + + self.emit("profiles_changed") + + async def handle_removed() -> None: + async for path in rem_conn_iter: + self.profiles[path].delete() + del self.profiles[path] + + self.emit("profiles_changed") + + await asyncio.gather(handle_new(), handle_removed()) + + async def initialize(self) -> None: + self.all_devices = await self.nm.devices + + await self._update_profiles() + self._profiles_updated_task = asyncio.create_task( + self._listen_connection_profiles() + ) + + for device_path in self.all_devices: + generic = NetworkDeviceGeneric(device_path) + + if await generic.capabilities & Capabilities.IS_SOFTWARE: + continue + + interface = await generic.interface + device_type = DeviceType(await generic.device_type) + state = DeviceState(await generic.state) + + self.logger.debug(f"{interface}: {state.name}") + + if device_type == DeviceType.ETHERNET: + eth_device = WiredDevice(device_path) + await eth_device.initialize() + eth_device.on( + "request_activation", + lambda dev: asyncio.create_task(self.activate_ethernet_device(dev)), + ) + eth_device.on( + "ip_config_changed", + lambda eth_device=eth_device: self.emit( + "ip_config_changed", eth_device + ), + ) + eth_device.on( + "state_changed", + lambda old_state, new_state, eth_device=eth_device: self.emit( + "state_changed", eth_device + ), + ) + self.ethernet_devices.append(eth_device) + + # TODO: Wireless diff --git a/backend_py/src/services/network/nm_wrapper.py b/backend_py/src/services/network/nm_wrapper.py new file mode 100644 index 00000000..03e53a54 --- /dev/null +++ b/backend_py/src/services/network/nm_wrapper.py @@ -0,0 +1,139 @@ +import asyncio +import logging +import time + +import socketio +from event_emitter import EventEmitter +from pydantic import BaseModel + +from .async_network_manager import ( + AsyncNetworkManager, + DeviceState, + IPV4Configuration, + IPV4Method, +) + + +class WiredDeviceModel(BaseModel): + interface: str + state: DeviceState + is_active: bool + active_profile_id: str | None = None + active_ip_configuration: IPV4Configuration | None = None + available_profiles: list[str] + + +class ConnectionProfileModel(BaseModel): + id: str + path: str + ipv4_settings: IPV4Configuration + + +class NetworkWrapper(EventEmitter): + def __init__(self, sio: socketio.AsyncServer) -> None: + super().__init__() + + self.logger = logging.getLogger("dwe_os_2.network.NetworkWrapper") + + self.nm = AsyncNetworkManager() + self.sio = sio + + self.last_connection_time = time.time() + + @self.sio.on("connect") + def on_connect(sid, environ) -> None: + self.logger.info(f"Connection detected: {sid}") + self.last_connection_time = time.time() + + self.nm.on("profile_updated", lambda profile: self._refresh_ui()) + self.nm.on("profiles_changed", lambda: self._refresh_ui()) + self.nm.on("ip_config_changed", lambda device: self._refresh_ui()) + self.nm.on("state_changed", lambda device: self._refresh_ui()) + + self._rollback_timer_task: asyncio.Task | None = None + + def _refresh_ui(self) -> None: + self.emit("refresh_ui") + + async def initialize(self) -> None: + await self.nm.initialize() + + def get_wired_devices(self) -> list[WiredDeviceModel]: + device_models = [] + for device in self.nm.ethernet_devices: + device_model = WiredDeviceModel( + interface=device.interface or "", + state=device.state, + active_profile_id=device.connection_profile_path, + active_ip_configuration=device.active_ip_configuration, + is_active=device.has_active_connection, + available_profiles=[ + profile.dbus_path + for profile in self.nm.get_compatible_profiles(device) + ], + ) + device_models.append(device_model) + return device_models + + def get_connection_profiles(self) -> list[ConnectionProfileModel]: + connection_profiles = [] + for profile in self.nm.profiles.values(): + if not profile.ipv4_settings or not profile.id: + continue + profile_model = ConnectionProfileModel( + id=profile.id, + path=profile.dbus_path, + ipv4_settings=profile.ipv4_settings, + ) + connection_profiles.append(profile_model) + return connection_profiles + + async def update_connection_profile( + self, path: str, ip_configuration: IPV4Configuration + ) -> bool: + profile = self.nm.get_profile(path) + if profile: + await profile.update_ipv4_configuration(ip_configuration) + await profile.save() + return True + + return False + + async def activate_interface( + self, interface: str, profile_path: str, enable_rollback=True + ) -> bool: + profile = self.nm.get_profile(profile_path) + device = self.nm.get_device_by_iface(interface) + if not profile or not device: + return False + + time_of_change = time.time() + + await self.nm.activate_ethernet_device(device, profile) + + if enable_rollback: + if self._rollback_timer_task: + self._rollback_timer_task.cancel() + self._rollback_timer_task = asyncio.create_task( + self._rollback_timer(interface, profile_path, time_of_change, 30) + ) + + return True + + async def _force_dhcp(self, interface: str, profile_path: str) -> None: + safe_ip_config = IPV4Configuration(method=IPV4Method.auto, never_default=False) + + await self.update_connection_profile(profile_path, safe_ip_config) + await self.activate_interface(interface, profile_path, False) + + async def _rollback_timer( + self, interface: str, profile_path: str, time_of_change: float, timeout: int + ) -> None: + await asyncio.sleep(timeout) + + if self.last_connection_time < time_of_change: + self.logger.error("Lockout detected! Forcing DHCP") + + await self._force_dhcp(interface, profile_path) + else: + self.logger.info("Active connection detected, not forcing rollback!") diff --git a/backend_py/src/services/preferences/__init__.py b/backend_py/src/services/preferences/__init__.py index 4d562731..9433f665 100644 --- a/backend_py/src/services/preferences/__init__.py +++ b/backend_py/src/services/preferences/__init__.py @@ -1,2 +1,4 @@ -from .preferences_manager import * -from .pydantic_schemas import * \ No newline at end of file +from .preferences_manager import PreferencesManager +from .pydantic_schemas import SavedPreferencesModel + +__all__ = ["PreferencesManager", "SavedPreferencesModel"] diff --git a/backend_py/src/services/preferences/preferences_manager.py b/backend_py/src/services/preferences/preferences_manager.py index e5f316c8..4d90deef 100644 --- a/backend_py/src/services/preferences/preferences_manager.py +++ b/backend_py/src/services/preferences/preferences_manager.py @@ -1,47 +1,51 @@ """ preference_manager.py -Manages persistence of server settings by reading from / writing to server_preferences.json +Manages persistence of server settings by reading from / writing to +server_preferences.json + Handles loading saved prefs and updating the json when settings are modified """ import json -from typing import Dict -from .pydantic_schemas import SavedPreferencesModel +import pathlib + from event_emitter import events -class PreferencesManager(events.EventEmitter): +from .pydantic_schemas import SavedPreferencesModel + - def __init__(self, settings_path: str = '.') -> None: +class PreferencesManager(events.EventEmitter): + def __init__(self, settings_path: str = ".") -> None: super().__init__() - path = f'{settings_path}/server_preferences.json' - try: - self.file_object = open(path, 'r+') - except FileNotFoundError: - open(path, 'w').close() - self.file_object = open(path, 'r+') - - try: - settings: list[Dict] = json.loads(self.file_object.read()) - self.settings: SavedPreferencesModel = SavedPreferencesModel.model_validate(settings) - except json.JSONDecodeError: - self.settings = SavedPreferencesModel() + self.path = pathlib.Path(settings_path, "server_preferences.json") + self.settings = SavedPreferencesModel() + self._load_settings() - def save(self, preferences: SavedPreferencesModel): + def save(self, preferences: SavedPreferencesModel) -> None: self.settings = preferences self.emit("preferences_updated", preferences) self._save_settings() - def get_preferences(self): + def get_preferences(self) -> SavedPreferencesModel: + # FIXME: why return self.settings - def serialize_preferences(self): + def serialize_preferences(self) -> SavedPreferencesModel: return self.settings - # TODO: make thread safe - def _save_settings(self): - self.file_object.seek(0) - self.file_object.write(self.settings.model_dump_json()) - self.file_object.truncate() - self.file_object.flush() + def _load_settings(self) -> None: + try: + with self.path.open("r") as f: + settings: dict = json.loads(f.read()) + self.settings: SavedPreferencesModel = ( + SavedPreferencesModel.model_validate(settings) + ) + except FileNotFoundError: + self.settings = SavedPreferencesModel() + + def _save_settings(self) -> None: + # CHECK: is this thread safe? + with self.path.open("w", encoding="utf-8") as f: + f.write(self.settings.model_dump_json(indent=4)) diff --git a/backend_py/src/services/preferences/pydantic_schemas.py b/backend_py/src/services/preferences/pydantic_schemas.py index 28099edd..7a15198a 100644 --- a/backend_py/src/services/preferences/pydantic_schemas.py +++ b/backend_py/src/services/preferences/pydantic_schemas.py @@ -5,11 +5,14 @@ Includes schemas for saved preferences, like default stream endpoints """ -from pydantic import BaseModel, Field -from typing import Optional +from pydantic import BaseModel + from ..cameras.pydantic_schemas import StreamEndpointModel + class SavedPreferencesModel(BaseModel): - default_stream: Optional[StreamEndpointModel] = StreamEndpointModel(host='192.168.2.1', port=5600) + default_stream: StreamEndpointModel | None = StreamEndpointModel( + host="192.168.2.1", port=5600 + ) suggest_host: bool = True frequency_offset: float = 0 diff --git a/backend_py/src/services/recordings/__init__.py b/backend_py/src/services/recordings/__init__.py index 2fb7c962..c49d4f5f 100644 --- a/backend_py/src/services/recordings/__init__.py +++ b/backend_py/src/services/recordings/__init__.py @@ -5,13 +5,12 @@ Allows for the renaming, deletion, and compression of the found recordings """ -from functools import lru_cache import json +import logging import os import subprocess -import threading import zipfile -import logging + from pydantic import BaseModel @@ -25,30 +24,30 @@ class RecordingInfo(BaseModel): class RecordingsService: - def __init__(self): + def __init__(self) -> None: self.recordings_path = os.path.join(os.getcwd(), "videos") self.recordings: list[RecordingInfo] = [] self.logger = logging.getLogger("dwe_os_2.RecordingsService") - threading.Thread(target=self.get_recordings, daemon=True).start() + self.durations = {} - def get_recordings(self): + def get_recordings(self) -> list[RecordingInfo]: if not os.path.exists(self.recordings_path): os.makedirs(self.recordings_path) self.recordings = [] for filename in os.listdir(self.recordings_path): - if filename.endswith(('.mp4', '.avi')): + if filename.endswith((".mp4", ".avi")): file_path = os.path.join(self.recordings_path, filename) file_stat = os.stat(file_path) recording_info = RecordingInfo( path=file_path, - name=filename.split('.')[0], - format=filename.split('.')[-1], + name=filename.split(".")[0], + format=filename.split(".")[-1], duration=self._get_duration(file_path), created=self._epoch_to_readable(file_stat.st_ctime), - size=f"{file_stat.st_size / (1024 * 1024):.2f} MB" + size=f"{file_stat.st_size / (1024 * 1024):.2f} MB", ) self.recordings.append(recording_info) @@ -56,36 +55,53 @@ def get_recordings(self): def _epoch_to_readable(self, epoch: float) -> str: from datetime import datetime - return datetime.fromtimestamp(epoch).strftime('%Y-%m-%d %H:%M:%S') - @lru_cache(maxsize=10000) + return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M:%S") + def _get_duration(self, file_path: str) -> str: + if file_path in self.durations: + self.logger.info(f"Found cached duration: {file_path}") + return self.durations[file_path] + + # FIXME: We need to change this function to use a better metadata library try: result = subprocess.run( - ['exiftool', '-json', file_path], + ["exiftool", "-json", file_path], capture_output=True, - text=True + text=True, + check=True, ) output = result.stdout.strip() if output: data = json.loads(output) - if file_path.endswith('.mp4'): - duration = data[0].get('Duration', '00:00:00') + + if file_path.endswith(".mp4"): + duration = data[0].get("Duration", "00:00:00") if "s" in duration: - duration = float(duration.replace(" s", "")) - return f"00:00:{round(duration):02}" + duration = ( + f"00:00:{round(float(duration.replace(' s', ''))):02}" + ) + self.durations[file_path] = duration return duration - totalFrameCount = data[0].get('TotalFrameCount', 0) - frameRate = data[0].get('FrameRate', 0) + + totalFrameCount = data[0].get("TotalFrameCount", 0) + frameRate = data[0].get("FrameRate", 0) if frameRate > 0: duration = totalFrameCount / frameRate hours = int(duration // 3600) minutes = int((duration % 3600) // 60) seconds = int(duration % 60) - return f"{hours:02}:{minutes:02}:{seconds:02d}" + duration = f"{hours:02}:{minutes:02}:{seconds:02d}" + self.durations[file_path] = duration + return duration + return "00:00:00" + except FileNotFoundError as e: + self.logger.error(f"exiftool was not found: {e}") + except json.JSONDecodeError as e: + self.logger.error(f"Error decoding output from exiftool: {e}") except Exception as e: - self.logger.error(f"Error getting duration: {e}") + self.logger.error(f"exiftool had an unknown system error: {e}") return "Unknown" def get_recording(self, filename: str) -> RecordingInfo | None: @@ -96,16 +112,22 @@ def get_recording(self, filename: str) -> RecordingInfo | None: return recording return None - def delete_recording(self, filename: str): + def delete_recording(self, filename: str) -> list[RecordingInfo] | None: + if filename in self.durations: + self.durations.pop(filename, None) + recording_path = os.path.join(self.recordings_path, filename) if os.path.exists(recording_path): os.remove(recording_path) self.recordings = [ - rec for rec in self.recordings if rec.path != recording_path] + rec for rec in self.recordings if rec.path != recording_path + ] return self.recordings - return False + return None - def rename_recording(self, old_name: str, new_name: str): + def rename_recording( + self, old_name: str, new_name: str + ) -> list[RecordingInfo] | None: old_path = os.path.join(self.recordings_path, old_name) new_path = os.path.join(self.recordings_path, new_name) @@ -113,21 +135,22 @@ def rename_recording(self, old_name: str, new_name: str): os.rename(old_path, new_path) for recording in self.recordings: if recording.path == old_path: - recording.name = new_name.split('.')[0] + recording.name = new_name.split(".")[0] recording.path = new_path - recording.format = new_name.split('.')[-1] + recording.format = new_name.split(".")[-1] return self.recordings - return False + return None - def zip_recordings(self): + def zip_recordings(self) -> str | None: self.get_recordings() # Refresh the recordings list if not self.recordings: return None zip_filename = os.path.join(self.recordings_path, "recordings.zip") - with zipfile.ZipFile(zip_filename, 'w') as zipf: + with zipfile.ZipFile(zip_filename, "w") as zipf: for recording in self.recordings: - zipf.write(recording.path, arcname=recording.name + - '.' + recording.format) + zipf.write( + recording.path, arcname=recording.name + "." + recording.format + ) return zip_filename diff --git a/backend_py/src/services/system/__init__.py b/backend_py/src/services/system/__init__.py index 878319ce..0b2f004f 100644 --- a/backend_py/src/services/system/__init__.py +++ b/backend_py/src/services/system/__init__.py @@ -1 +1,3 @@ -from .system_manager import * \ No newline at end of file +from .system_manager import SystemManager + +__all__ = ["SystemManager"] diff --git a/backend_py/src/services/system/system_manager.py b/backend_py/src/services/system/system_manager.py index 33a7e0a1..abd4352e 100644 --- a/backend_py/src/services/system/system_manager.py +++ b/backend_py/src/services/system/system_manager.py @@ -1,22 +1,24 @@ -import os import logging +import subprocess class SystemManager: - """ - Simple class to manage the restarting and shutting down of the system - """ - - REBOOT_COMMAND = "reboot now" - SHUTDOWN_COMMAND = "shutdown now" + REBOOT_COMMAND = ["reboot", "now"] + SHUTDOWN_COMMAND = ["shutdown", "now"] def __init__(self) -> None: self.logger = logging.getLogger("dwe_os_2.SystemManager") - def restart_system(self): + def restart_system(self) -> None: self.logger.info("Restarting system") - os.system(self.REBOOT_COMMAND) + try: + subprocess.run(self.REBOOT_COMMAND, check=True) + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to restart system: {e}") - def shutdown_system(self): + def shutdown_system(self) -> None: self.logger.info("Shutting down system") - os.system(self.SHUTDOWN_COMMAND) + try: + subprocess.run(self.SHUTDOWN_COMMAND, check=True) + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to shutdown system: {e}") diff --git a/backend_py/src/services/ttyd/__init__.py b/backend_py/src/services/ttyd/__init__.py index 2fcbada5..72a12c75 100644 --- a/backend_py/src/services/ttyd/__init__.py +++ b/backend_py/src/services/ttyd/__init__.py @@ -1 +1,3 @@ -from .ttyd import * \ No newline at end of file +from .ttyd import TTYDManager + +__all__ = ["TTYDManager"] diff --git a/backend_py/src/services/ttyd/ttyd.py b/backend_py/src/services/ttyd/ttyd.py index d549de72..a8114ecc 100644 --- a/backend_py/src/services/ttyd/ttyd.py +++ b/backend_py/src/services/ttyd/ttyd.py @@ -6,22 +6,24 @@ import subprocess + class TTYDManager: - - TTYD_CMD = ['ttyd', '-p', '7681', 'login'] + TTYD_CMD = ["ttyd", "-p", "7681", "login"] def __init__(self, is_dev_mode=False) -> None: self._process: subprocess.Popen | None = None if is_dev_mode: - self.TTYD_CMD = ['ttyd', '-W', '-a', '-p', '7681', 'bash'] + self.TTYD_CMD = ["ttyd", "-W", "-a", "-p", "7681", "bash"] def start(self) -> None: if self._process: return - self._process = subprocess.Popen(self.TTYD_CMD, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self._process = subprocess.Popen( + self.TTYD_CMD, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) - def kill(self): + def kill(self) -> None: if self._process: self._process.kill() diff --git a/backend_py/src/services/wifi/__init__.py b/backend_py/src/services/wifi/__init__.py deleted file mode 100644 index 3b895873..00000000 --- a/backend_py/src/services/wifi/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .async_network_manager import * -from .exceptions import * -from .wifi_types import * diff --git a/backend_py/src/services/wifi/async_network_manager.py b/backend_py/src/services/wifi/async_network_manager.py deleted file mode 100644 index 1ebe76b9..00000000 --- a/backend_py/src/services/wifi/async_network_manager.py +++ /dev/null @@ -1,475 +0,0 @@ -""" -async_network_manager.py - -Acts as an asyncronous wrapper for network_manager.py, preventing blocking in the FastAPI server -Asycronizes the slow DBus calls (scanning / connecting) to separate threads, prevents freezing -""" -import asyncio -import time -from typing import Callable, List -from event_emitter import EventEmitter - -from .wifi_types import IPConfiguration, Status, Connection, IPType, NetworkPriority -from .network_manager import NetworkManager -from .exceptions import WiFiException -import subprocess -from .network_manager import AccessPoint, ConnectionType -import logging - -from enum import Enum - - -class CommandType(str, Enum): - CONNECT = "connect" - DISCONNECT = "disconnect" - FORGET = "forget" - UPDATE_IP = "update_ip" - CHANGE_NETWORK_PRIORITY = "priority" - - -class Command: - def __init__(self, cmd_type: CommandType, *args, **kwargs): - self.args = args - self.kwargs = kwargs - self.cmd_type = cmd_type - - self.future = asyncio.get_running_loop().create_future() - - def set_result(self, result=None): - if not self.future.done(): - self.future.set_result(result) - - def set_exception(self, exc: Exception): - if not self.future.done(): - self.future.set_exception(exc) - - -class AsyncNetworkManager(EventEmitter): - - def __init__(self, scan_interval=10): - super().__init__() - try: - self.nm = NetworkManager() - except Exception as e: - print(e) - raise WiFiException("NetworkManager is not supported") from e - - self._nm_lock = asyncio.Lock() - - self._command_queue: asyncio.Queue[Command] = asyncio.Queue() - - self.logger = logging.getLogger("dwe_os_2.wifi.AsyncNetworkManager") - - self._ip_configuration = {} - self._network_priority = NetworkPriority.ETHERNET - - self.scan_interval = scan_interval - self._scanning = False - self._scan_task = None - self._update_task = None - self._requested_scan = False - - self.status = Status() - self.connections = [] - self.access_points = [] - - # self.nm.set_static_ip("192.168.2.101", 24, prioritize_wireless=True) - # self.nm.set_dynamic_ip(prioritize_wireless=True) - - try: - self._initialize_access_points() - self._initialize_ip_configuration() - except Exception as e: - print(e) - raise WiFiException("No ethernet device") - - async def _reinitialize_nm(self): - self.logger.info("Reinitializing NetworkManager") - async with self._nm_lock: - self.nm.reinit() - - async def safe_dbus_call(self, method, *args, timeout=3): - """ - Runs a DBus method call safely with a timeout. - - :param method: The DBus function to call. - :param args: Arguments to pass to the function. - :param timeout: Timeout in seconds before forcefully stopping. - :return: The result of the function or None if it times out. - """ - try: - return await asyncio.wait_for( - asyncio.to_thread(method, *args), timeout=timeout - ) - except asyncio.TimeoutError: - return None # Handle failure gracefully - except Exception as e: - import traceback - traceback.print_exc() - self.logger.info(e) - await self._reinitialize_nm() - return None - - def get_network_priority(self): - return self._network_priority - - def _ping_ip(self, ip: str, interface_name: str | None = None): - """ - Method to ping an IP address - - :param ip: The IP address to ping - :param interface_name: The name of the interface to ping the IP address on - :return: True if the IP address is reachable, False otherwise - """ - try: - if interface_name is not None: - subprocess.check_output( - ["ping", "-I", interface_name, "-c", "4", ip]) - else: - subprocess.check_output(["ping", "-c", "4", ip]) - return True - except subprocess.CalledProcessError: - return False - - def _initialize_ip_configuration(self): - self._ip_configuration = self.nm.get_ip_info() - - if self._ip_configuration == None: - self.logger.info("No ethernet connection detected") - # elif self._ip_configuration.ip_type == IPType.STATIC: - # self.logger.info( - # f"Static IP: {self._ip_configuration.static_ip}/{self._ip_configuration.prefix}, Gateway: {self._ip_configuration.gateway}" - # ) - # else: - # self.logger.info( - # f"Dynamic IP: {self._ip_configuration.static_ip}/{self._ip_configuration.prefix}" - # ) - - def _initialize_access_points(self): - """ - Initialize AP list - """ - try: - self.access_points = self.nm.get_access_points() - except Exception as e: - raise WiFiException( - f"Error occurred while initializing access points {e}" - ) from e - - def get_ip_configuration(self): - return self._ip_configuration - - def start_scanning(self): - self._scanning = True - self._update_task = asyncio.create_task(self._update_loop()) - - self.logger.info("WiFi manager scanning started") - - async def stop_scanning(self): - self._scanning = False - - if self._scan_task: - await self._scan_task - if self._update_task: - await self._update_task - - self.logger.info("WiFi manager scanning stopped") - - def get_access_points(self) -> List[AccessPoint]: - return self.access_points - - def _requires_password(self, access_points: AccessPoint) -> bool: - return self.nm._ap_requires_password(access_points.flags, access_points.wpa_flags, access_points.rsn_flags) - - def get_status(self): - return self.status - - def list_connections(self): - return [Connection(id=i.id, type=i.type) for i in self.connections] - - async def set_network_priority(self, network_priority: NetworkPriority): - self._network_priority = network_priority - cmd = Command(CommandType.CHANGE_NETWORK_PRIORITY, network_priority) - await self._command_queue.put(cmd) - return await cmd.future - - async def set_ip_configuration(self, ip_configuration: IPConfiguration): - cmd = Command(CommandType.UPDATE_IP, ip_configuration) - await self._command_queue.put(cmd) - return await cmd.future - - async def connect(self, ssid: str, password: str = ""): - cmd = Command(CommandType.CONNECT, ssid, password) - await self._command_queue.put(cmd) - return await cmd.future - - async def disconnect(self): - cmd = Command(CommandType.DISCONNECT) - await self._command_queue.put(cmd) - return await cmd.future - - async def forget(self, ssid: str): - cmd = Command(CommandType.FORGET, ssid) - await self._command_queue.put(cmd) - return await cmd.future - - async def _update_loop(self): - start_time = time.time() - while self._scanning: - try: - current_time = time.time() - try: - cmd = await asyncio.wait_for( - self._command_queue.get(), timeout=0.01 - ) - except asyncio.TimeoutError: - # No command arrived, make sure we check self._scanning - cmd = None - - if cmd is not None: - await self._process_command(cmd) - self._command_queue.task_done() - - await self._update_connections() - await self._update_active_connection() - - async with self._nm_lock: - # Update ip configuration - new_ip_configuration = await self._get_ip_info_safe() - if new_ip_configuration != self._ip_configuration: - self._ip_configuration = new_ip_configuration - self.logger.info("IP Configuration changed") - self.emit("ip_changed") - - async with self._nm_lock: - if self._requested_scan and await self.safe_dbus_call( - self.nm.has_finished_scan - ): - self.status.finished_first_scan = True - self._requested_scan = False - new_access_points = await self.safe_dbus_call( - self.nm.get_access_points - ) - # Can't just do this - # if new_access_points != self.access_points: - # self.emit("aps_changed") - - # do this: - current_ssids = {ap.ssid for ap in self.access_points} - new_ssids = {ap.ssid for ap in new_access_points} - - if new_ssids - current_ssids != set(): - self.emit("aps_changed") - - self.access_points = new_access_points - - # Only scan every 10 seconds - if current_time - start_time > self.scan_interval: - start_time = current_time - async with self._nm_lock: - res = await self.safe_dbus_call(self.nm.request_wifi_scan) - if res == None: - # Scanning failed - await asyncio.sleep(1) - else: - self._requested_scan = True - - await asyncio.sleep(5) - except Exception as e: - self.logger.exception("Exception in _update_loop: %s", e) - - async def _process_command(self, cmd: Command): - if cmd.cmd_type == CommandType.CONNECT: - ssid, password = cmd.args - await self._handle_connect(cmd, ssid, password) - elif cmd.cmd_type == CommandType.DISCONNECT: - await self._handle_disconnect(cmd) - elif cmd.cmd_type == CommandType.FORGET: - (ssid,) = cmd.args - await self._handle_forget(cmd, ssid) - elif cmd.cmd_type == CommandType.UPDATE_IP: - (ip_configuration,) = cmd.args - await self._handle_update_ip(cmd, ip_configuration) - elif cmd.cmd_type == CommandType.CHANGE_NETWORK_PRIORITY: - (network_priority,) = cmd.args - await self._handle_change_network_priority(cmd, network_priority) - else: - cmd.set_exception(ValueError( - f"Unknown command type: {cmd.cmd_type}")) - - async def _get_ip_info_safe(self): - try: - return await asyncio.to_thread(self.nm.get_ip_info) - except Exception as e: - self.logger.error( - f'NetworkManager Exception Occurred while getting IP Information {e}') - await self._reinitialize_nm() - - async def _set_static_ip( - self, ip_configuration: IPConfiguration, prioritize_wireless=False - ): - """do not call""" - return await self.safe_dbus_call( - self.nm.set_static_ip, - ip_configuration.static_ip, - ip_configuration.prefix, - ip_configuration.gateway or "0.0.0.0", - ip_configuration.dns, - prioritize_wireless - ) - - async def _handle_change_network_priority( - self, cmd: Command, network_priority: NetworkPriority - ): - async with self._nm_lock: - if self._ip_configuration is None: - return - if network_priority == NetworkPriority.ETHERNET: - await self._set_static_ip(self._ip_configuration) - cmd.set_result(True) - else: # Wireless Priority - await self._set_static_ip(self._ip_configuration, True) - cmd.set_result(True) - - async def _handle_update_ip(self, cmd: Command, ip_configuration: IPConfiguration): - try: - async with self._nm_lock: - if ip_configuration.ip_type == IPType.STATIC: - await self._set_static_ip(ip_configuration) - cmd.set_result(True) - else: - # Run sync dynamic IP in thread - await asyncio.to_thread(self.nm.set_dynamic_ip) - cmd.set_result(True) - except Exception as e: - cmd.set_exception(e) - - async def _is_connected(self, ssid: str): - if not self.nm.get_active_wireless_connection(): - return False - return self.nm.get_active_wireless_connection().id == ssid - - async def _handle_connect(self, cmd: Command, ssid: str, password: str = ""): - try: - async with self._nm_lock: - # Run sync connect in thread to avoid blocking - await asyncio.to_thread(self.nm.connect, ssid, password) - - if await self._wait_for(lambda: self._is_connected(ssid)): - self.status.connected = False - self.status.connection = None - cmd.set_result(True) - else: - cmd.set_result(False) - except Exception as e: - cmd.set_exception(e) - - async def _handle_disconnect(self, cmd: Command): - try: - async with self._nm_lock: - # Run sync disconnect in thread - await asyncio.to_thread(self.nm.disconnect) - - if await self._wait_for( - lambda: self.nm.get_active_wireless_connection() is None - ): - self.status.connected = False - self.status.connection = None - cmd.set_result(True) - else: - cmd.set_result(False) - - except Exception as e: - cmd.set_exception(e) - - async def _handle_forget(self, cmd: Command, ssid: str): - try: - async with self._nm_lock: - # Run sync forget in thread - await asyncio.to_thread(self.nm.forget, ssid) - - cmd.set_result(True) - - except Exception as e: - cmd.set_exception(e) - - async def _wait_for(self, generator: Callable[[], bool], timeout=10): - for _ in range(timeout): - if generator(): - return True - await asyncio.sleep(1) - return False - - async def _update_connections(self): - if self.nm is None: - return - try: - async with self._nm_lock: - connections = await self.safe_dbus_call( - self.nm.list_wireless_connections - ) - if connections != self.connections: - self.emit("connections_changed") - self.connections = connections - except Exception as e: - self.logger.error( - f"Error occurred while fetching cached connections: {e}") - await self._reinitialize_nm() - - async def _update_active_connection(self): - if self.nm is None: - return - try: - async with self._nm_lock: - # Fetch active connection via safe async call - connection = await self.safe_dbus_call(self.nm.get_active_wireless_connection) - # Extract id and type in thread to avoid blocking - if connection is not None: - conn_id, ap_type = await asyncio.to_thread(lambda c: (c.id, c.connection_type), connection) - else: - conn_id, ap_type = None, None - formattedConnection = Connection(id=conn_id, type=ap_type) - if connection is not None: - if ( - self.status.connection != formattedConnection - and not self.status.connected - ): - self.status.connection = formattedConnection - self.status.connected = True - self.emit("connected") - elif self.status.connected: - self.status.connection = formattedConnection - self.emit("connection_changed") - else: - if self.status.connected: - self.emit("disconnected") - self.status.connection = Connection() - self.status.connected = False - except Exception as e: - # An error regarding path will occur sometimes when the connection has not re-activated - self.logger.error( - f"Error occurred while fetching active connection: {e}") - - async def turn_off_wifi(self): - """ - Turn off WiFi - """ - try: - async with self._nm_lock: - await asyncio.to_thread(self.nm.turn_off_wifi) - return True - except Exception as e: - self.logger.error(f"Error occurred while turning off WiFi: {e}") - return False - - async def turn_on_wifi(self): - """ - Turn on WiFi - """ - try: - async with self._nm_lock: - await asyncio.to_thread(self.nm.turn_on_wifi) - return True - except Exception as e: - self.logger.error(f"Error occurred while turning on WiFi: {e}") - return False diff --git a/backend_py/src/services/wifi/exceptions.py b/backend_py/src/services/wifi/exceptions.py deleted file mode 100644 index e77eadc7..00000000 --- a/backend_py/src/services/wifi/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class WiFiException(Exception): - '''Thrown when there is some kind of issue with the WiFiManager''' \ No newline at end of file diff --git a/backend_py/src/services/wifi/network_manager.py b/backend_py/src/services/wifi/network_manager.py deleted file mode 100644 index 87d258aa..00000000 --- a/backend_py/src/services/wifi/network_manager.py +++ /dev/null @@ -1,534 +0,0 @@ -""" -network_manager.py - -Manages system network connections by communicating with system NetworkManager through DBus -Handles Wifi scanning and connection management (connect / disconnect / forget) and IP Configuration (static / dynamic) for wired/wireless interfaces -""" - -import ipaddress -from typing import List, Dict, Any -# from .wifi_types import Connection, AccessPoint, IPConfiguration, IPType -import logging -import time -import sdbus -import sdbus -from sdbus_block.networkmanager import NetworkManagerSettings, NetworkManager as NetworkManagerDBUS, ActiveConnection, NetworkDeviceGeneric, DeviceType, NetworkDeviceWired, NetworkConnectionSettings, NetworkDeviceWireless, IPv4Config, AccessPoint, ConnectionType, NetworkManagerConnectionProperties -from sdbus_block.networkmanager.exceptions import NmConnectionInvalidPropertyError -import uuid -import subprocess - - -class NetworkManager: - """ - Class for interfacing with NetworkManager over dbus - """ - - def __init__(self) -> None: - self._last_scan_timestamp: int | None = None - - # Get the system bus - self.bus = sdbus.sd_bus_open_system() - sdbus.set_default_bus(self.bus) - self.networkmanager = NetworkManagerDBUS() - # Get a local proxy to the NetworkManager object - - self.logger = logging.getLogger("dwe_os_2.wifi.NetworkManager") - - def reinit(self): - self.bus.close() - - del self.bus - del self.networkmanager - time.sleep(0.1) - - # Get the system bus - self.bus = sdbus.sd_bus_open_system() - - sdbus.set_default_bus(self.bus) - self.networkmanager = NetworkManagerDBUS() - - def get_ip_info(self, interface_name: str | None = None) -> Dict[str, Any] | None: - """ - Get the IP address - - :return: The IP address - """ - - # TODO: get ip of either active ethernet or wireless - - try: - ethernet_device, connection = self._get_eth_device_and_connection() - - ethernet_device = self._get_ethernet_device( - interface_name) - if ethernet_device is None: - raise Exception("No ethernet device found") - - ipv4_config = IPv4Config( - ethernet_device.ip4_config, self.bus - ) - - addresses = ipv4_config.address_data - - # method = self.get_connection_method(connection.id) - dns_arr = [i['address'] for i in ipv4_config.nameserver_data or []] - - if len(addresses) == 0: - return None - method = self.get_connection_method(connection) - - return dict( - static_ip=addresses[0]["address"][1], - prefix=addresses[0]["prefix"][1], - gateway=self.get_ip_gateway(connection), - dns=[i[1] for i in dns_arr], - ip_type="STATIC" if method == "manual" else "DYNAMIC", - ) - except Exception: - return None - - def get_ipv4_settings(self, connection: ActiveConnection) -> Dict: - return NetworkConnectionSettings(connection.connection, self.bus).get_settings().get("ipv4") - - def get_ip_gateway(self, connection: ActiveConnection): - ipv4_settings = IPv4Config(connection.ip4_config, self.bus) - return ipv4_settings.gateway - - def get_connection_method(self, connection: ActiveConnection) -> str: - """ - Get the method of a connection - - :param connection_id: The ID of the connection to get the method of - :return: The method of the connection (manual = static, auto = dynamic) - """ - ipv4_settings = self.get_ipv4_settings(connection) - return ipv4_settings.get("method")[1] - - def _get_eth_device_and_connection( - self, interface_name: str | None = None, connection_id: str | None = None - ) -> 'tuple[NetworkDeviceWired, ActiveConnection]': - # Get the first ethernet device - ethernet_device = self._get_ethernet_device(interface_name) - connection = ActiveConnection( - ethernet_device.active_connection, self.bus - ) - return (ethernet_device, connection) - - def _update_ipv4_settings( - self, - settings: Dict[str, any], - connection: ActiveConnection | None = None - ): - if connection is None: - _, connection = self._get_eth_device_and_connection() - network_settings = NetworkConnectionSettings( - connection.connection, self.bus) - - all_connection_settings = network_settings.get_settings() - all_connection_settings["ipv4"] = settings - network_settings.update(all_connection_settings) - network_settings.save() - - def set_static_ip( - self, - ip_address: str, - prefix: int, - gateway: str | None = None, - dns_servers: List[str] = [], - prioritize_wireless=False, - connection: ActiveConnection | None = None, - ): - """ - Set the static IP address - - :param interface_name: The name of the interface to set the static IP address on - :param ip_address: The IP address to set - :param prefix: The CIDR prefix length of the IP address - :param gateway: The gateway to use - :param dns_servers: The DNS servers to use - :param connection_id: The ID of the connection to set the static IP address on - :return: The interface name of the ethernet device - """ - - print( - f"Setting static IP {ip_address}/{prefix} with gateway {gateway} and DNS servers {dns_servers}") - # Update the IPv4 configuration, leaving everything else the same - ipv4_settings = { - "method": ("s", "manual"), - "address-data": - ("aa{sv}", [{ - "address": ("s", ip_address), - "prefix": ("u", int(prefix)), - }]), - "dns": ("au", [int(ipaddress.IPv4Address(dns).packed.hex(), 16) for dns in dns_servers]), - } - - # If we prioritize wireless, there is no reason to have a default gateway, since we will always use the wireless one - if prioritize_wireless: - ipv4_settings["route-metric"] = ("u", 200) - # ipv4_settings["never-default"] = True - else: - ipv4_settings["route-metric"] = ("u", 0) - if gateway is not None: - ipv4_settings["gateway"] = ("s", gateway) - - # Update the connection and return the result - return self._update_ipv4_settings(ipv4_settings, connection=connection) - - def set_dynamic_ip( - self, - interface_name: str | None = None, - prioritize_wireless=False, - connection: ActiveConnection | None = None, - ): - """ - Set the dynamic IP address - - :param interface_name: The name of the interface to set the dynamic IP address on - :param connection_id: The ID of the connection to set the dynamic IP address on - :return: The interface name of the ethernet device - """ - ipv4_settings = { - "method": ("s", "auto"), - "never-default": ("b", True), - } - - if prioritize_wireless: - ipv4_settings["route-metric"] = ("u", 200) - ipv4_settings["never-default"] = ("b", True) - - return self._update_ipv4_settings(ipv4_settings, connection=connection) - - def _find_connection_by_id(self, connection_id: str) -> ActiveConnection | None: - """ - Find a connection by its ID - """ - for connection in self._list_connections(): - if connection.id == connection_id: - return connection - - def _get_ethernet_device(self, interface_name: str | None = None) -> NetworkDeviceWired: - """ - Get the path of the ethernet device with the given interface name - - :param interface_name: The name of the interface to get the ethernet device for - :return: The path of the ethernet device - """ - devices = self.networkmanager.get_devices() - - if not devices: - raise Exception("No devices found") - - devs = [] - - for dev_path in devices: - device = NetworkDeviceGeneric(dev_path, self.bus) - - dev_type = device.device_type - - if dev_type == DeviceType.ETHERNET: - devs.append(NetworkDeviceWired( - dev_path, self.bus - )) - - if len(devs) == 0: - raise Exception("No ethernet devices found") - - # If an interface name is provided, return the device with the matching interface name - # Otherwise, return the first ethernet device found - if interface_name: - for device in devs: - if device.interface == interface_name: - return device - return devs[0] - - def connect(self, ssid: str, password="") -> bool: - """ - Connects to a Wi-Fi network using the provided SSID and password. - - Args: - ssid: The SSID (network name) of the Wi-Fi network. - password: The password for the Wi-Fi network. - - Returns: - True if the connection was successful, False otherwise. - """ - wifi_device = self._get_wifi_device() - - if not wifi_device: - return False - - # Try to find an existing connection for this SSID - existing_connection = None - for connection in self.networkmanager.active_connections: - try: - settings = NetworkConnectionSettings( - connection, self.bus).get_settings() - if 'ssid' in settings.get('802-11-wireless', {}) and \ - settings['802-11-wireless']['ssid'].decode('utf-8') == ssid: - existing_connection = connection - break - except: - # Device becomes out of range, turns off, etc. - continue - - if existing_connection: - try: - self.networkmanager.activate_connection( - existing_connection, wifi_device, '/') - - return True - except Exception as e: - return False - else: - - uuid_id = str(uuid.uuid4()) - - connection_id = ssid - - properties: NetworkManagerConnectionProperties = { - "connection": { - "id": ("s", ssid), - "uuid": ("s", uuid_id), - "type": ("s", "802-11-wireless"), - "autoconnect": ("b", bool(True)), - }, - "802-11-wireless": { - "mode": ("s", "infrastructure"), - "security": ("s", "802-11-wireless-security"), - "ssid": ("ay", ssid.encode("utf-8")), - }, - "802-11-wireless-security": { - "key-mgmt": ("s", "wpa-psk"), - "psk": ("s", password), - }, - "ipv4": {"method": ("s", "auto")}, - "ipv6": {"method": ("s", "auto")}, - } - nm_settings = NetworkManagerSettings(self.bus) - try: - nm_settings.add_connection(properties) - except NmConnectionInvalidPropertyError as e: - raise Exception( - "Can't Connect to wifi. Make sure password is correct") - password_bytes = str(password + '\n') - activate_cmd = ["nmcli", "--ask", - "connection", "up", connection_id] - subprocess.run(activate_cmd, input=password_bytes, - capture_output=True, text=True, check=True) - - def disconnect(self): - """ - Disconnect from any connected network - """ - wifi_dev = self._get_wifi_device() - - if not wifi_dev: - raise Exception("No WiFi device found") - - active_connection = ActiveConnection( - wifi_dev.active_connection, self.bus) - self.networkmanager.deactivate_connection(active_connection) - - def list_wireless_connections(self) -> List[ActiveConnection]: - """ - Get a list of the active wireless connections - """ - return self.list_connections() - - def get_active_wireless_connection(self) -> ActiveConnection | None: - """ - Get the first active wireless connection - """ - active_wireless_conections = list(self.get_active_connections()) - return ( - None - if len(active_wireless_conections) == 0 - else active_wireless_conections[0] - ) - - def list_connections(self, only_wireless=True) -> List[ActiveConnection]: - """ - Get a list of all the connections saved - """ - connections = [] - for connection in self._list_connections(): - if ( - not only_wireless - or connection.connection_type == ConnectionType.WIRELESS - and connection not in connections - ): - connections.append(connection) - return connections - - def get_active_connections(self, wireless_only=True) -> List[ActiveConnection]: - """ - Get a list of active connections, including wired - """ - active_connections = self.networkmanager.active_connections - connections = [] - for connection_path in active_connections: - connection = ActiveConnection(connection_path, self.bus) - - if not wireless_only or connection.connection_type == ConnectionType.WIFI: - connections.append( - connection - ) - - return connections - - def get_access_points(self) -> List[AccessPoint]: - """ - Get wifi networks without a scan - """ - wifi_dev = self._get_wifi_device() - - if not wifi_dev: - raise Exception("No WiFi device found") - return self._get_access_points(wifi_dev) - - def request_wifi_scan(self) -> None: - """ - Scan wifi networks - """ - wifi_dev = self._get_wifi_device() - - if not wifi_dev: - raise Exception("No WiFi device found") - - # get the timestamp of the last scan - self._last_scan_timestamp = wifi_dev.last_scan - - # request a scan - wifi_dev.request_scan({}) - - def has_finished_scan(self): - wifi_dev = self._get_wifi_device() - - if not wifi_dev: - raise Exception("No WiFi device found") - - current_scan = wifi_dev.last_scan - if current_scan != self._last_scan_timestamp: - return True - - return False - - def forget(self, ssid: str): - """ - Forget a network - """ - for connection in self._list_connections(): - config = connection.GetSettings() - # ensure config being None cannot cause issues - if config is None: - self.logger.warning("Failed to get config from connection") - continue - try: - if config["connection"]["id"] == ssid: - connection.Delete() - except KeyError as e: - raise Exception( - f"Error occurred when attempting to forget network: {str(e)}" - ) - - """ - NOTE: All private functions should not have DBusException error handling - """ - - def _ap_requires_password(self, flags: int, wpa_flags: int, rsn_flags: int): - """ - Check if a given access point requires password - """ - NM_802_11_AP_FLAGS_PRIVACY = 0x1 - - # check the overall flags and additionally check if there are any security flags which would indicate a password is needed - return ( - flags & NM_802_11_AP_FLAGS_PRIVACY == 1 or wpa_flags != 0 or rsn_flags != 0 - ) - - def _get_wifi_device(self) -> NetworkDeviceWireless | None: - devices = self.networkmanager.get_devices() - if devices is None: - self.logger.warning("Failed to retrieve device list") - devices = [] - for dev_path in devices: - device = NetworkDeviceGeneric(dev_path, self.bus) - dev_type = device.device_type - - # is wifi device - if dev_type == DeviceType.WIFI: - return NetworkDeviceWireless(dev_path, self.bus) - return None - - def _get_access_points(self, wifi_dev: NetworkDeviceWireless) -> List[AccessPoint]: - """ - Get a list of access points. Should only be called after scanning for networks - """ - access_points: List[AccessPoint] = [] - wifi_access_points = wifi_dev.access_points - if wifi_access_points is None: - return [] - for ap_path in wifi_access_points: - access_points.append( - AccessPoint(ap_path, self.bus) - ) - - return sorted(access_points, key=lambda ap: ap.strength, reverse=True) - - def _list_connections(self) -> List[ActiveConnection]: - connections = [] - - # List all the connections saved - # This might have repeats for some reason, so this needs to be filtered - for device in self.networkmanager.get_devices(): - if device is None: - continue - if not isinstance(device, NetworkDeviceGeneric): - continue - if device.active_connection is None: - continue - - connection = ActiveConnection(device.active_connection, self.bus) - connections.append(connection) - - return connections - - def turn_off_wifi(self): - """ - Turn off the WiFi device completely - """ - wifi_dev = self._get_wifi_device() - - if not wifi_dev: - raise Exception("No WiFi device found") - - # Alternative approach using nmcli command for more reliable wifi disabling - try: - subprocess.run(["nmcli", "radio", "wifi", "off"], - check=True, capture_output=True) - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to turn off WiFi using nmcli: {e}") - raise Exception("Failed to turn off WiFi") - - def turn_on_wifi(self): - """ - Turn on the WiFi device - """ - try: - subprocess.run(["nmcli", "radio", "wifi", "on"], - check=True, capture_output=True) - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to turn on WiFi using nmcli: {e}") - raise Exception("Failed to turn on WiFi") - - def is_wifi_enabled(self) -> bool: - """ - Check if WiFi is currently enabled - """ - try: - result = subprocess.run( - ["nmcli", "radio", "wifi"], check=True, capture_output=True, text=True) - return result.stdout.strip() == "enabled" - except subprocess.CalledProcessError: - return False diff --git a/backend_py/src/services/wifi/wifi_types.py b/backend_py/src/services/wifi/wifi_types.py deleted file mode 100644 index 5b29283b..00000000 --- a/backend_py/src/services/wifi/wifi_types.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -wifi_types.py - -Defines Pydantic models and Enums for wifi operations -Includes schemas for wifi networks (ssid, signal stength), security requirements, and connection states used by system NetworkManager -""" - - -from pydantic import BaseModel, Field -from typing import Optional, List -from enum import Enum - - -class NetworkPriority(str, Enum): - # AUTO = "AUTO" - ETHERNET = "ETHERNET" - WIRELESS = "WIRELESS" - - -class NetworkPriorityInformation(BaseModel): - network_priority: NetworkPriority - - -class IPType(str, Enum): - STATIC = "STATIC" - DYNAMIC = "DYNAMIC" - - -class IPConfiguration(BaseModel): - static_ip: Optional[str] = "" - gateway: Optional[str] = "" - # CIDR prefix length - prefix: Optional[int] = 24 - ip_type: Optional[IPType] = IPType.STATIC - dns: Optional[List[str]] = Field(default_factory=list) - - -class NetworkConfig(BaseModel): - ssid: str - password: Optional[str] = None - - -class Connection(BaseModel): - id: Optional[str] = None - type: Optional[str] = None - - -class Status(BaseModel): - connection: Optional[Connection] = Field(default_factory=Connection) - finished_first_scan: bool = False - connected: bool = False - - -class AccessPoint(BaseModel): - ssid: str - strength: int - requires_password: bool - -class ConnectionResultModel(BaseModel): - result: bool diff --git a/backend_py/src/types.py b/backend_py/src/types.py index 50ae380b..94f93752 100644 --- a/backend_py/src/types.py +++ b/backend_py/src/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass + @dataclass class FeatureSupport: ttyd: bool wifi: bool @classmethod - def all(cls): + def all(cls) -> "FeatureSupport": return cls(ttyd=True, wifi=True) diff --git a/create_release.sh b/create_release.sh index 6828e2fb..6b972fdb 100755 --- a/create_release.sh +++ b/create_release.sh @@ -42,4 +42,6 @@ cp create_venv.sh release cp run_release.sh release cp -r service release +rm -rf release/backend_py/videos + tar -czvf release.tar.gz release diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86a90add..185d43f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,23 +1,23 @@ { "name": "frontend", - "version": "v1.0.0-JetsonTesting", + "version": "v0.6.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "v1.0.0-JetsonTesting", + "version": "v0.6.0-dev", "dependencies": { "@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-scroll-area": "^1.2.6", "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.3", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.3", @@ -37,12 +37,14 @@ "cmdk": "^1.1.1", "lucide-react": "^0.456.0", "motion": "^12.23.26", + "next-themes": "^0.4.6", "openapi-fetch": "^0.13.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^6.28.0", "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", "valtio": "^2.1.4" @@ -1847,12 +1849,35 @@ } }, "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz", - "integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -2302,12 +2327,35 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.4.tgz", - "integrity": "sha512-2fTm6PSiUm8YPq9W0E4reYuv01EE3aFSzt8edBiXqPHshF8N9+Kymt/k0/R+F3dkY5lQyB/zPtrP82phskLi7w==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -2919,9 +2967,9 @@ } }, "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -2929,9 +2977,9 @@ } }, "node_modules/@redocly/openapi-core/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -2951,9 +2999,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", - "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -2965,9 +3013,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", - "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -2979,9 +3027,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz", - "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -2993,9 +3041,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz", - "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -3007,9 +3055,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz", - "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -3021,9 +3069,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz", - "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -3035,9 +3083,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz", - "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -3049,9 +3097,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz", - "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -3063,9 +3111,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz", - "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -3077,9 +3125,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz", - "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -3090,10 +3138,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz", - "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -3104,10 +3152,38 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz", - "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -3119,9 +3195,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz", - "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -3133,9 +3209,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz", - "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -3147,9 +3223,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz", - "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -3161,9 +3237,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz", - "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], @@ -3175,9 +3251,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz", - "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -3188,10 +3264,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz", - "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -3203,9 +3307,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz", - "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -3216,10 +3320,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", - "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -3291,9 +3409,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -3517,9 +3635,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -3527,13 +3645,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3717,9 +3835,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3881,9 +3999,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4214,9 +4332,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4718,9 +4836,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4857,21 +4975,21 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5994,9 +6112,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6098,6 +6216,16 @@ "dev": true, "license": "MIT" }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -6357,9 +6485,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -6863,13 +6991,13 @@ } }, "node_modules/rollup": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz", - "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -6879,26 +7007,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.0", - "@rollup/rollup-android-arm64": "4.40.0", - "@rollup/rollup-darwin-arm64": "4.40.0", - "@rollup/rollup-darwin-x64": "4.40.0", - "@rollup/rollup-freebsd-arm64": "4.40.0", - "@rollup/rollup-freebsd-x64": "4.40.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", - "@rollup/rollup-linux-arm-musleabihf": "4.40.0", - "@rollup/rollup-linux-arm64-gnu": "4.40.0", - "@rollup/rollup-linux-arm64-musl": "4.40.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-musl": "4.40.0", - "@rollup/rollup-linux-s390x-gnu": "4.40.0", - "@rollup/rollup-linux-x64-gnu": "4.40.0", - "@rollup/rollup-linux-x64-musl": "4.40.0", - "@rollup/rollup-win32-arm64-msvc": "4.40.0", - "@rollup/rollup-win32-ia32-msvc": "4.40.0", - "@rollup/rollup-win32-x64-msvc": "4.40.0", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -7010,33 +7143,26 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "node_modules/source-map-js": { @@ -7896,15 +8022,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yaml-ast-parser": { diff --git a/frontend/package.json b/frontend/package.json index 3884b356..009c394e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,72 +1,74 @@ { - "name": "frontend", - "private": true, - "version": "v1.0.0-JetsonTesting", - "type": "module", - "scripts": { - "dev": "vite --host", - "build": "tsc -b; vite build", - "regenerate-schemas": "openapi-typescript http://localhost:5000/openapi.json -o ./src/schemas/dwe_os_2.d.ts", - "lint": "eslint .", - "preview": "vite preview", - "schema-pull": "openapi-typescript http://localhost:5000/openapi.json -o ./src/schemas/dwe_os_2.d.ts" - }, - "dependencies": { - "@radix-ui/react-accordion": "^1.2.10", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-checkbox": "^1.2.3", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-radio-group": "^1.3.6", - "@radix-ui/react-scroll-area": "^1.2.6", - "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.3.3", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.3", - "@radix-ui/react-tabs": "^1.1.9", - "@radix-ui/react-toast": "^1.2.6", - "@radix-ui/react-toggle": "^1.1.10", - "@radix-ui/react-tooltip": "^1.1.4", - "@xterm/addon-canvas": "^0.7.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-image": "^0.8.0", - "@xterm/addon-unicode11": "^0.8.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "lucide-react": "^0.456.0", - "motion": "^12.23.26", - "openapi-fetch": "^0.13.5", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-markdown": "^10.1.0", - "react-router-dom": "^6.28.0", - "socket.io-client": "^4.8.1", - "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7", - "valtio": "^2.1.4" - }, - "devDependencies": { - "@eslint/js": "^9.13.0", - "@types/node": "^22.9.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.3", - "autoprefixer": "^10.4.20", - "eslint": "^9.13.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.14", - "globals": "^15.11.0", - "openapi-typescript": "^7.6.1", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.14", - "typescript": "~5.6.2", - "typescript-eslint": "^8.11.0", - "vite": "^5.4.10" - } + "name": "frontend", + "private": true, + "version": "v0.6.0-dev", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "tsc -b && vite build", + "regenerate-schemas": "openapi-typescript http://localhost:5000/openapi.json -o ./src/schemas/dwe_os_2.d.ts", + "lint": "eslint .", + "preview": "vite preview", + "schema-pull": "openapi-typescript http://localhost:5000/openapi.json -o ./src/schemas/dwe_os_2.d.ts" + }, + "dependencies": { + "@radix-ui/react-accordion": "^1.2.10", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.2.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-radio-group": "^1.3.6", + "@radix-ui/react-scroll-area": "^1.2.6", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.3", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.9", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-tooltip": "^1.1.4", + "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-image": "^0.8.0", + "@xterm/addon-unicode11": "^0.8.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^0.456.0", + "motion": "^12.23.26", + "next-themes": "^0.4.6", + "openapi-fetch": "^0.13.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.28.0", + "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "valtio": "^2.1.4" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "openapi-typescript": "^7.6.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.14", + "typescript": "~5.6.2", + "typescript-eslint": "^8.11.0", + "vite": "^5.4.10" + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e4a7a7a..5ea54e28 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,9 +19,7 @@ import { CommandPalette } from "./components/dwe/app/command-palette"; import { io, Socket } from "socket.io-client"; import { useEffect, useRef, useState } from "react"; import WebsocketContext from "./contexts/WebsocketContext"; -import { Toaster } from "@/components/ui/toaster"; -import { WifiDropdown } from "./components/dwe/wireless/wifi-dropdown"; -import { WiredDropdown } from "./components/dwe/wireless/wired-dropdown"; +import { Toaster } from "@/components/ui/sonner"; import { SystemDropdown } from "./components/dwe/system/system-dropdown"; import { API_CLIENT } from "./api"; import { TourAlertDialog, TourProvider, useTour } from "@/components/tour/tour"; @@ -105,8 +103,6 @@ function AppContent() {
- {features?.wifi ? : <>} - {features?.wifi ? : <>}
@@ -155,12 +151,12 @@ function App() { return ( - + ); } diff --git a/frontend/src/components/dwe/cameras/cam-control-map.json b/frontend/src/components/dwe/cameras/cam-control-map.json index 20fbbaa7..598923a6 100644 --- a/frontend/src/components/dwe/cameras/cam-control-map.json +++ b/frontend/src/components/dwe/cameras/cam-control-map.json @@ -21,6 +21,7 @@ "Power Line Frequency", "Bitrate", "Group of Pictures", - "Variable Bitrate" + "Variable Bitrate", + "HW Bitrate" ] } diff --git a/frontend/src/components/dwe/cameras/camera-controls.tsx b/frontend/src/components/dwe/cameras/camera-controls.tsx index d7fd7228..c550269e 100644 --- a/frontend/src/components/dwe/cameras/camera-controls.tsx +++ b/frontend/src/components/dwe/cameras/camera-controls.tsx @@ -28,7 +28,7 @@ import BooleanControl from "./controls/boolean-control"; import MenuControl from "./controls/menu-control"; import { components } from "@/schemas/dwe_os_2"; import { API_CLIENT } from "@/api"; -import { useToast } from "@/hooks/use-toast"; +import { toast } from "sonner"; import CameraControlMap from "./cam-control-map.json"; import { Accordion, @@ -50,7 +50,6 @@ const ControlWrapper = ({ index: number; }) => { const key = control.control_id ?? `control-${index}`; - const { toast } = useToast(); const device = useContext(DeviceContext)!; const bus_info = device.bus_info; @@ -67,7 +66,7 @@ const ControlWrapper = ({ }, }).catch((error) => { console.error("Failed to set UVC control:", control_id, error); - toast({ title: error, variant: "destructive" }); + toast.error(error); }); }; @@ -126,7 +125,6 @@ const ControlWrapper = ({ export const CameraControls = () => { const device = useContext(DeviceContext)!; const controls = device.controls; - const { toast } = useToast(); const [isOpen, setIsOpen] = useState(false); const resetControls = () => { @@ -135,7 +133,7 @@ export const CameraControls = () => { control.value = control.flags.default_value; } }); - toast({ title: "Camera controls reset to default values." }); + toast.info("Camera controls reset to default values."); }; const supportedControls = controls.filter((c) => diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index 8271fcd6..11f2e1ab 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -16,7 +16,7 @@ import { import DevicesContext from "@/contexts/DevicesContext"; import { getDeviceByBusInfo } from "@/lib/utils"; import NotConnected from "../not-connected"; -import { useToast } from "@/hooks/use-toast"; +import { toast } from "sonner"; import { useTour } from "@/components/tour/tour"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; @@ -101,8 +101,6 @@ const NoDevicesConnected = () => { const DeviceListLayout = () => { const { socket, connected } = useContext(WebsocketContext)!; - const { toast } = useToast(); - const { isActive } = useTour(); const [devices, setDevices] = useState([] as DeviceModel[]); @@ -221,10 +219,8 @@ const DeviceListLayout = () => { } return [...currentDevices]; // Return a new array to trigger re-render }); - toast({ - title: "Stream Error", + toast.error("Stream Error", { description: `An error occurred with the device ${data.bus_info}. Please check the logs for more details.`, - variant: "destructive", }); }; diff --git a/frontend/src/components/dwe/log-page/log-detail-view.tsx b/frontend/src/components/dwe/log-page/log-detail-view.tsx index 9c4a0ef6..69fa7028 100644 --- a/frontend/src/components/dwe/log-page/log-detail-view.tsx +++ b/frontend/src/components/dwe/log-page/log-detail-view.tsx @@ -29,7 +29,7 @@ export function LogDetailView({ try { const date = new Date(timestamp.replace(",", ".")); return date.toLocaleString(); - } catch (e) { + } catch { return timestamp; } }; diff --git a/frontend/src/components/dwe/network/wired/wired-config.tsx b/frontend/src/components/dwe/network/wired/wired-config.tsx new file mode 100644 index 00000000..c7580f82 --- /dev/null +++ b/frontend/src/components/dwe/network/wired/wired-config.tsx @@ -0,0 +1,509 @@ +import { API_CLIENT } from "@/api"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { components } from "@/schemas/dwe_os_2"; +import { useContext, useEffect, useState } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Check, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import WebsocketContext from "@/contexts/WebsocketContext"; +import { IP_REGEX } from "../../preferences/preferences"; + +type WiredDeviceModel = components["schemas"]["WiredDeviceModel"]; + +type ConnectionProfileModel = components["schemas"]["ConnectionProfileModel"]; + +type IPV4Configuration = components["schemas"]["IPV4Configuration"]; + +const DeviceStateLookup = { + 0: "UNKNOWN", + 10: "UNMANAGED", + 20: "UNAVAILABLE", + 30: "DISCONNECTED", + 40: "PREPARE", + 50: "CONFIG", + 60: "NEED_AUTH", + 70: "IP_CONFIG", + 80: "IP_CHECK", + 90: "SECONDARIES", + 100: "ACTIVATED", + 110: "DEACTIVATING", + 120: "FAILED", +}; + +function AddressEdit({ + address, + prefix, + onUpdate, + onDelete, +}: { + address?: string; + prefix?: number; + key: string; + onUpdate: (address: string, prefix: number) => void; + onDelete: () => void; +}) { + const [addressState, setAddressState] = useState(address || ""); + const [prefixState, setPrefixState] = useState(prefix?.toString() || ""); + const [isValidPrefix, setIsValidPrefix] = useState(true); + + useEffect(() => { + if ( + !isNaN(Number(prefixState)) && + Number.isInteger(parseInt(prefixState)) + ) { + const newPrefix = parseInt(prefixState); + console.log(newPrefix); + if ( + newPrefix !== prefix || + (addressState != address && IP_REGEX.test(addressState)) + ) { + onUpdate(addressState, newPrefix); + setIsValidPrefix(true); + } + } else { + setIsValidPrefix(false); + } + }, [addressState, prefixState, onUpdate, prefix]); + + return ( + + + { + setAddressState(e.target.value); + }} + /> + + + { + setPrefixState(e.target.value); + }} + /> + + + + + + + ); +} + +function EditProfileDialog({ + profile, + isOpen, + setIsOpen, + onSave, +}: { + profile: ConnectionProfileModel; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + onSave: (newConfig: IPV4Configuration) => void; +}) { + const config = profile.ipv4_settings; + + const [method, setMethod] = useState(config.method || "auto"); + const [ipAddresses, setIpAddresses] = useState(config.ip_addresses || []); + const [gateway, setGateway] = useState(config.gateway || ""); + const [dns, setDns] = useState(config.dns?.join(", ") || ""); + const [neverDefault, setNeverDefault] = useState( + config.never_default || false, + ); + + useEffect(() => { + if (isOpen && config) { + setMethod(config.method); + setIpAddresses(config.ip_addresses || []); + setGateway(config.gateway || ""); + setDns(config.dns?.join(", ") || ""); + } + }, [isOpen, config]); + + const handleSave = () => { + console.log(ipAddresses); + const updatedConfig: IPV4Configuration = { + method: method as components["schemas"]["IPV4Method"], + ip_addresses: method === "manual" && ipAddresses ? ipAddresses : [], + gateway: method === "manual" ? gateway : "", + dns: + method === "manual" && dns ? dns.split(",").map((d) => d.trim()) : [], + never_default: neverDefault, + }; + onSave(updatedConfig); + setIsOpen(false); + }; + + if (!profile) return null; + + return ( + + + + Edit "{profile.id}" + +
+
+ + +
+ + {/* Manual */} + {method == "manual" && ( +
+
+ + + + + Address + Prefix + + + + {ipAddresses.map((address, index) => ( + { + setIpAddresses((prev) => + prev.map((element, updateIndex) => + updateIndex === index + ? { address, prefix } + : element, + ), + ); + }} + onDelete={() => { + setIpAddresses((prev) => + prev.filter( + (_, deletedIndex) => deletedIndex !== index, + ), + ); + }} + /> + ))} + +
+
+ +
+ +
+ +
+ + setDns(e.target.value)} + placeholder="8.8.8.8, 1.1.1.1" + /> +
+ +
+ + setGateway(e.target.value)} + placeholder="192.168.2.1" + /> +
+
+ )} + + {/* Shared settings */} +
+ +
+ setNeverDefault((prev) => !prev)} + /> +
+ +
+
+
+
+ + + +
+
+ ); +} + +function ConnectionProfile({ + profile, + isActive, + onSelect, + master_device, +}: { + profile: ConnectionProfileModel; + isActive: boolean; + master_device?: WiredDeviceModel; + onSelect: () => void; +}) { + const [isEditing, setIsEditing] = useState(false); + + const onSave = (newIPConfiguration: IPV4Configuration) => { + API_CLIENT.POST("/api/network/update_connection_profile", { + params: { + query: { path: profile.path }, + }, + body: newIPConfiguration, + }); + }; + + return ( + <> + +
  • + {/* Icon Area */} +
    + + {/* Text Content */} +
    +
    +
    +
    + {profile.id} + {isActive && } +
    + + {/* Edit Button */} + +
    + + {isActive && ( +
    +
    + {/* IP Row */} + IPv4 Address + + {master_device?.active_ip_configuration?.ip_addresses + ?.map((address) => `${address.address}/${address.prefix}`) + .join(", ")} + + + {/* Gateway Row */} + Default Route + + {master_device?.active_ip_configuration?.gateway || "-"} + + + {/* DNS Row */} + DNS + + {master_device?.active_ip_configuration?.dns?.join(",") || + "-"} + +
    +
    + )} +
    +
    +
  • + + ); +} + +function WiredDevice({ + wired_device, + profiles, +}: { + wired_device: WiredDeviceModel; + profiles: { [key: string]: ConnectionProfileModel }; +}) { + console.log(profiles); + + return ( + + + + {wired_device.interface}: {DeviceStateLookup[wired_device.state]} + + +
      + {wired_device.available_profiles.map((path) => { + return ( + { + API_CLIENT.POST("/api/network/wired/activate_profile", { + params: { + query: { + interface: wired_device.interface, + profile_path: path, + }, + }, + }); + }} + /> + ); + })} +
    +
    +
    +
    + ); +} + +export default function WiredConfig() { + const [devices, setDevices] = useState([] as WiredDeviceModel[]); + + const { connected, socket } = useContext(WebsocketContext)!; + + const refresh_interface = () => { + API_CLIENT.GET("/api/network/wired/devices").then((result) => { + const devicesData = result.data!; + API_CLIENT.GET("/api/network/connection_profiles").then(({ data }) => { + const profileMap = data?.reduce( + (acc, profile) => { + acc[profile.path] = profile; + return acc; + }, + {} as Record, + ); + + console.log(profileMap); + + if (profileMap) setProfiles(profileMap); + setDevices(devicesData); + }); + }); + }; + + useEffect(() => { + if (connected) { + socket?.on("refresh_wired_config", refresh_interface); + } + }, [connected, socket]); + + const [profiles, setProfiles] = useState<{ + [key: string]: ConnectionProfileModel; + }>({}); + + useEffect(() => { + refresh_interface(); + }, []); + + return ( + + + Wired Configuration + + +
    + {devices.map((dev) => ( + + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/components/dwe/network/wireless/wireless-config.tsx b/frontend/src/components/dwe/network/wireless/wireless-config.tsx new file mode 100644 index 00000000..cf0b3cb2 --- /dev/null +++ b/frontend/src/components/dwe/network/wireless/wireless-config.tsx @@ -0,0 +1,29 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function WirelessConfig() { + return ( +
    + + + Wireless Configuration + + +
    +

    + No supported wireless device found. +

    +
    +
    + + For more detailed documentation, refer to our docs. + +
    +
    + ); +} diff --git a/frontend/src/components/dwe/preferences/preferences.tsx b/frontend/src/components/dwe/preferences/preferences.tsx index 42e1fb86..b77457af 100644 --- a/frontend/src/components/dwe/preferences/preferences.tsx +++ b/frontend/src/components/dwe/preferences/preferences.tsx @@ -1,5 +1,5 @@ import { API_CLIENT } from "@/api"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,29 +12,15 @@ import NotConnected from "../not-connected"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; import { Button } from "@/components/ui/button"; import { useTour } from "@/components/tour/tour"; -import { RangeControl } from "@/components/ui/range-control"; +import { SettingsCard } from "./settings-card"; +import WiredConfig from "../network/wired/wired-config"; +import WirelessConfig from "../network/wireless/wireless-config"; import FeaturesContext from "@/contexts/FeaturesContext"; +import { RangeControl } from "@/components/ui/range-control"; export const IP_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9-]*[A-Za-z0-9])$/; -const SettingsCard = ({ - cardTitle, - children, -}: { - cardTitle: string; - children: React.ReactNode; -}) => { - return ( - - - {cardTitle} - - {children} - - ); -}; - const PreferencesLayout = () => { const { connected } = useContext(WebsocketContext)!; const features = useContext(FeaturesContext); @@ -206,6 +192,11 @@ const PreferencesLayout = () => { + +
    +
    {connected && }
    +
    {connected && }
    +
    ); }; diff --git a/frontend/src/components/dwe/preferences/settings-card.tsx b/frontend/src/components/dwe/preferences/settings-card.tsx new file mode 100644 index 00000000..1a4b5f41 --- /dev/null +++ b/frontend/src/components/dwe/preferences/settings-card.tsx @@ -0,0 +1,18 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export const SettingsCard = ({ + cardTitle, + children, +}: { + cardTitle: string; + children: React.ReactNode; +}) => { + return ( + + + {cardTitle} + + {children} + + ); +}; diff --git a/frontend/src/components/dwe/recordings/recordings.tsx b/frontend/src/components/dwe/recordings/recordings.tsx index dab1e8c7..6d0f3d25 100644 --- a/frontend/src/components/dwe/recordings/recordings.tsx +++ b/frontend/src/components/dwe/recordings/recordings.tsx @@ -68,7 +68,8 @@ const Recordings = () => { const { isActive } = useTour(); const sortRecordings = () => { - var modifier = (x: any) => x; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let modifier = (x: any) => x; if (sortColumn === "size") { modifier = (x: string) => parseFloat(x); } @@ -184,11 +185,13 @@ const Recordings = () => { }, []); const displayRecordings = useMemo(() => { - let data = isActive ? [DEMO_RECORDING] : recordings; + const data = isActive ? [DEMO_RECORDING] : recordings; if (!sortColumn || !sortDirection) return data; return [...data].sort((a, b) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any let valA: any = a[sortColumn]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let valB: any = b[sortColumn]; if (sortColumn === "size") { @@ -227,14 +230,14 @@ const Recordings = () => { rightClickedRecording?.name, ); if (newName && newName.trim() && rightClickedRecording) { - // @ts-ignore-next-line - API_CLIENT.PATCH( - // @ts-ignore-next-line - `/api/recordings/${rightClickedRecording.name}.${ - rightClickedRecording.format - }/${newName.trim()}.${rightClickedRecording.format}`, - {}, - ) + API_CLIENT.PATCH("/api/recordings/{old_name}/{new_name}", { + params: { + path: { + old_name: `${rightClickedRecording.name}.${rightClickedRecording.format}`, + new_name: `${newName.trim()}.${rightClickedRecording.format}`, + }, + }, + }) .then((newRecs) => { setRecordings(newRecs.data! as RecordingInfo[]); setShowMenu(false); @@ -254,12 +257,13 @@ const Recordings = () => { className="mx-2 my-1 px-2 py-1 hover:bg-red-500 hover:text-foreground cursor-pointer text-red-500 rounded-md" onClick={() => { if (rightClickedRecording) { - // @ts-ignore-next-line - API_CLIENT.DELETE( - // @ts-ignore-next-line - `/api/recordings/${rightClickedRecording.name}.${rightClickedRecording.format}`, - {}, - ) + API_CLIENT.DELETE("/api/recordings/{recording_path}", { + params: { + path: { + recording_path: `${rightClickedRecording.name}.${rightClickedRecording.format}`, + }, + }, + }) .then((newRecs) => { setRecordings(newRecs.data! as RecordingInfo[]); setShowMenu(false); @@ -477,16 +481,22 @@ const Recordings = () => { variant="outline" className="flex-1 min-w-[140px] h-12 text-background bg-destructive hover:text-foreground hover:bg-red-500" onClick={async () => { - // @ts-ignore-next-line - const new_recordings = ( - await API_CLIENT.DELETE( - // @ts-ignore-next-line - `/api/recordings/${selectedRecording.name}.${selectedRecording.format}`, - {}, - ) - ).data! as RecordingInfo[]; - setRecordings(new_recordings); - setSelectedRecording(null); + if (selectedRecording) { + const new_recordings = ( + await API_CLIENT.DELETE( + `/api/recordings/{recording_path}`, + { + params: { + path: { + recording_path: `${selectedRecording.name}.${selectedRecording.format}`, + }, + }, + }, + ) + ).data! as RecordingInfo[]; + setRecordings(new_recordings); + setSelectedRecording(null); + } }} > Delete diff --git a/frontend/src/components/dwe/system/system-dropdown.tsx b/frontend/src/components/dwe/system/system-dropdown.tsx index cfa8c9c5..cd918326 100644 --- a/frontend/src/components/dwe/system/system-dropdown.tsx +++ b/frontend/src/components/dwe/system/system-dropdown.tsx @@ -15,14 +15,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useToast } from "@/hooks/use-toast"; +import { toast } from "sonner"; import { API_CLIENT } from "@/api"; import { useState } from "react"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; export function SystemDropdown() { - const { toast } = useToast(); - const [dialogOpen, setDialogOpen] = useState(false); const [action, setAction] = useState<"restart" | "shutdown" | null>(null); @@ -32,13 +30,13 @@ export function SystemDropdown() { try { if (action === "restart") { await API_CLIENT.POST("/api/system/restart"); - toast({ title: "System is restarting..." }); + toast.info("System is restarting..."); } else if (action === "shutdown") { await API_CLIENT.POST("/api/system/shutdown"); - toast({ title: "System is shutting down..." }); + toast.info("System is shutting down..."); } - } catch (error) { - toast({ title: `Failed to ${action}`, variant: "destructive" }); + } catch { + toast.error(`Failed to ${action} system`); } finally { setDialogOpen(false); setAction(null); diff --git a/frontend/src/components/dwe/terminal/xterm/index.ts b/frontend/src/components/dwe/terminal/xterm/index.ts index 0c7fae86..32ae3782 100644 --- a/frontend/src/components/dwe/terminal/xterm/index.ts +++ b/frontend/src/components/dwe/terminal/xterm/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-duplicate-enum-values */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck @@ -76,7 +77,7 @@ function toDisposable(f: () => void): IDisposable { function addEventListener( target: EventTarget, type: string, - listener: EventListener + listener: EventListener, ): IDisposable { target.addEventListener(type, listener); return toDisposable(() => target.removeEventListener(type, listener)); @@ -107,7 +108,10 @@ export class Xterm { private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data)); - constructor(private options: XtermOptions, private sendCb: () => void) { + constructor( + private options: XtermOptions, + private sendCb: () => void, + ) { this.register = this.register.bind(this); this.refreshToken = this.refreshToken.bind(this); this.onWindowUnload = this.onWindowUnload.bind(this); @@ -187,34 +191,34 @@ export class Xterm { if (data && data !== "" && !this.titleFixed) { document.title = data + " | " + this.title; } - }) + }), ); register(terminal.onData((data) => sendData(data))); register( terminal.onBinary((data) => - sendData(Uint8Array.from(data, (v) => v.charCodeAt(0))) - ) + sendData(Uint8Array.from(data, (v) => v.charCodeAt(0))), + ), ); register( terminal.onResize(({ cols, rows }) => { const msg = JSON.stringify({ columns: cols, rows: rows }); this.socket?.send( - this.textEncoder.encode(Command.RESIZE_TERMINAL + msg) + this.textEncoder.encode(Command.RESIZE_TERMINAL + msg), ); if (this.resizeOverlay) overlayAddon.showOverlay(`${cols}x${rows}`, 300); - }) + }), ); register( terminal.onSelectionChange(() => { if (this.terminal.getSelection() === "") return; try { document.execCommand("copy"); - } catch (e) { + } catch { return; } this.overlayAddon?.showOverlay("\u2702", 200); - }) + }), ); register(addEventListener(window, "resize", () => fitAddon.fit())); register(addEventListener(window, "beforeunload", this.onWindowUnload)); @@ -266,13 +270,13 @@ export class Xterm { socket.binaryType = "arraybuffer"; register(addEventListener(socket, "open", this.onSocketOpen)); register( - addEventListener(socket, "message", this.onSocketData as EventListener) + addEventListener(socket, "message", this.onSocketData as EventListener), ); register( - addEventListener(socket, "close", this.onSocketClose as EventListener) + addEventListener(socket, "close", this.onSocketClose as EventListener), ); register( - addEventListener(socket, "error", () => (this.doReconnect = false)) + addEventListener(socket, "error", () => (this.doReconnect = false)), ); } @@ -336,7 +340,7 @@ export class Xterm { const { clientOptions } = this.options; const prefs = {} as Preferences; const queryObj = Array.from( - new URLSearchParams(query) as unknown as Iterable<[string, string]> + new URLSearchParams(query) as unknown as Iterable<[string, string]>, ); for (const [k, queryVal] of queryObj) { @@ -358,7 +362,7 @@ export class Xterm { break; default: console.warn( - `[ttyd] maybe unknown option: ${k}=${queryVal}, treating as string` + `[ttyd] maybe unknown option: ${k}=${queryVal}, treating as string`, ); prefs[k] = queryVal; break; @@ -461,7 +465,7 @@ export class Xterm { terminal.options[key] = Object.assign( {}, terminal.options[key], - value + value, ); } else { terminal.options[key] = value; @@ -499,7 +503,7 @@ export class Xterm { } catch (e) { console.log( "[ttyd] canvas renderer could not be loaded, falling back to dom renderer", - e + e, ); disposeCanvasRenderer(); } @@ -516,7 +520,7 @@ export class Xterm { } catch (e) { console.log( "[ttyd] WebGL renderer could not be loaded, falling back to canvas renderer", - e + e, ); disposeWebglRenderer(); enableCanvasRenderer(); diff --git a/frontend/src/components/dwe/wireless/wifi-dropdown.tsx b/frontend/src/components/dwe/wireless/wifi-dropdown.tsx deleted file mode 100644 index 74ee3fc8..00000000 --- a/frontend/src/components/dwe/wireless/wifi-dropdown.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { useContext, useEffect, useState } from "react"; -import { Wifi, WifiOff, Dot, Lock } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { API_CLIENT } from "@/api"; -import { components } from "@/schemas/dwe_os_2"; -import WebsocketContext from "@/contexts/WebsocketContext"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { useToast } from "@/hooks/use-toast"; -import { Switch } from "@/components/ui/switch"; -import { TOUR_STEP_IDS } from "@/lib/tour-constants"; - -export function WifiDropdown() { - const { toast } = useToast(); - const { connected, socket } = useContext(WebsocketContext)!; - - const [networks, setNetworks] = useState( - [] as components["schemas"]["AccessPoint"][], - ); - const [isWifiEnabled, setIsWifiEnabled] = useState(true); - const [isConnecting, setIsConnecting] = useState(false); - const [password, setPassword] = useState(undefined); - const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - const [selectedNetwork, setSelectedNetwork] = useState< - components["schemas"]["AccessPoint"] | null - >(null); - - const [wifiStatus, setWifiStatus] = useState< - undefined | components["schemas"]["Status"] - >(undefined); - - const updateWifiNetworks = async () => { - const wifiNetworks = (await API_CLIENT.GET("/api/wifi/access_points")) - .data!; - setNetworks(wifiNetworks); - }; - - const updateConnectedNetwork = async () => { - setWifiStatus((await API_CLIENT.GET("/api/wifi/status")).data!); - }; - - useEffect(() => { - if (connected) { - updateConnectedNetwork(); - updateWifiNetworks(); - - socket?.on("connection_changed", () => { - updateConnectedNetwork(); - }); - - socket?.on("aps_changed", () => { - updateWifiNetworks(); - }); - - return () => { - socket?.off("connection_changed"); - socket?.off("aps_changed"); - }; - } - - return () => {}; - }, [connected]); - - const toggleWifi = async () => { - await API_CLIENT.POST(isWifiEnabled ? "/wifi/off" : "/wifi/on"); - setIsWifiEnabled(!isWifiEnabled); - if (isWifiEnabled) { - // Disconnect from all networks when turning WiFi off - - setNetworks( - networks.map((network) => ({ ...network, connected: false })), - ); - } - }; - - const handleNetworkConnect = ( - network: components["schemas"]["AccessPoint"], - ) => { - if (network.requires_password) { - setSelectedNetwork(network); - setPassword(""); - setPasswordDialogOpen(true); - } else { - connectToNetwork(network.ssid); - } - }; - - const connectToNetwork = async (ssid: string, password?: string) => { - let result = ( - await API_CLIENT.POST("/api/wifi/connect", { - body: { ssid: ssid, password: password }, - }) - ).data!.result; - - if (result) { - toast({ - title: "WiFi Connected!", - variant: "default", - }); - } else { - toast({ - title: "Uh Oh! WiFi Connected Failed!", - variant: "destructive", - }); - } - setIsConnecting(false); - setSelectedNetwork(null); - setPassword(undefined); - setPasswordDialogOpen(false); - }; - - const handlePasswordSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedNetwork) return; - - setIsConnecting(true); - - connectToNetwork(selectedNetwork.ssid, password); - }; - - return ( -
    - - - - - - - WiFi - toggleWifi()} - /> - - - - {isWifiEnabled ? ( - <> - {networks - - .sort((a, b) => { - const connectedId = wifiStatus?.connection?.id; - - if (a.ssid === connectedId) return -1; // a is connected, put first - if (b.ssid === connectedId) return 1; // b is connected, put first - return b.strength - a.strength; // otherwise sort by strength - }) - .filter( - (network, index) => - networks.findIndex( - (findNetwork) => network.ssid === findNetwork.ssid, - ) === index, - ) - .map((network) => ( - handleNetworkConnect(network)} - > -
    - - - {network.ssid} - - {network.requires_password && ( - - )} -
    - {wifiStatus?.connection?.id == network.ssid && ( -
    - )} - - ))} - - - - Add Network - - - - ) : ( -
    - WiFi is turned off -
    - )} - - - {/* Password Dialog */} - - -
    - - Connect to {selectedNetwork?.ssid} - - This network is password protected. Please enter the password to - connect. - - -
    -
    - - setPassword(e.target.value)} - className="col-span-3" - required - autoFocus - /> -
    -
    - - - - -
    -
    -
    -
    - ); -} - -interface SignalStrengthProps { - strength: number; // 1-4, where 4 is the strongest -} - -function SignalStrength({ strength }: SignalStrengthProps) { - const thresholds = [20, 50, 70]; - - return ( -
    - {thresholds.map((threshold, index) => ( -
    = threshold ? "bg-foreground" : "bg-muted-foreground/30", - `h-${index + 2}`, - )} - /> - ))} -
    - ); -} diff --git a/frontend/src/components/dwe/wireless/wired-dropdown.tsx b/frontend/src/components/dwe/wireless/wired-dropdown.tsx deleted file mode 100644 index 36bec4bc..00000000 --- a/frontend/src/components/dwe/wireless/wired-dropdown.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { Network } from "lucide-react"; // Added Settings and Save icons -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; -import { API_CLIENT } from "@/api"; -import { components } from "@/schemas/dwe_os_2"; // Assuming your schema is at this path -import WebsocketContext from "@/contexts/WebsocketContext"; -import { useToast } from "@/hooks/use-toast"; -import { useContext, useEffect, useState } from "react"; - -// Import Shadcn UI components for form elements -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { TOUR_STEP_IDS } from "@/lib/tour-constants"; - -// Import types from the generated schema -type IPConfiguration = components["schemas"]["IPConfiguration"]; -type IPType = components["schemas"]["IPType"]; - -export function WiredDropdown() { - const { connected, socket } = useContext(WebsocketContext)!; - - // State for the current IP configuration fetched from the API - const [ipConfiguration, setIpConfiguration] = useState< - IPConfiguration | undefined - >(undefined); - - // State for the form inputs - initially null or empty - const [formIpType, setFormIpType] = useState(null); - const [formStaticIp, setFormStaticIp] = useState(""); - const [formPrefix, setFormPrefix] = useState(null); - const [formGateway, setFormGateway] = useState(""); - const [formDns, setFormDns] = useState(""); // Stored as comma-separated string - - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); // State for saving process - const { toast } = useToast(); - - // Function to fetch the current IP configuration - const updateIPConfiguration = async () => { - setIsLoading(true); - try { - const { error, data } = await API_CLIENT.GET( - "/api/wired/get_ip_configuration", - ); - - if (error) { - console.error("Error fetching IP configuration:", error); - setIpConfiguration(undefined); - } else if (data) { - setIpConfiguration(data); - setFormIpType(data.ip_type); - setFormStaticIp(data.static_ip || ""); - setFormPrefix(data.prefix ?? 24); - setFormGateway(data.gateway || ""); - setFormDns(data.dns ? data.dns.join(", ") : ""); - } else { - setIpConfiguration(undefined); - // Reset form states when no configuration is found - setFormIpType(null); - setFormStaticIp(""); - setFormPrefix(null); - setFormGateway(""); - setFormDns(""); - toast({ - // Toast on initial load if no ethernet is found - title: "Wired Network", - description: "No wired network detected.", - variant: "default", - }); - } - } catch (e) { - console.error("API call error:", e); - setIpConfiguration(undefined); - toast({ - title: "Error", - description: "An unexpected error occurred while fetching IP config.", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; - - // Function to handle saving the form - const handleSaveConfiguration = async () => { - if (!formIpType) { - toast({ - title: "Validation Error", - description: "Please select an IP type.", - variant: "destructive", - }); - return; - } - - setIsSaving(true); - const payload: IPConfiguration = { - ip_type: formIpType, - static_ip: formIpType === "STATIC" ? formStaticIp || null : null, // Only include if STATIC - prefix: formIpType === "STATIC" ? (formPrefix ?? null) : null, // Only include if STATIC - gateway: formIpType === "STATIC" ? formGateway || null : null, // Only include if STATIC - dns: formDns - ? formDns - .split(",") - .map((d) => d.trim()) - .filter((d) => d !== "") // Split, trim, remove empty - : null, // Send null if empty string - }; - - if (payload.ip_type === "STATIC") { - if (!payload.static_ip || !payload.prefix || !payload.gateway) { - toast({ - title: "Validation Error", - description: - "IP Address, Prefix, and Gateway are required for Static IP.", - variant: "destructive", - }); - setIsSaving(false); - return; - } - } - - try { - const { error } = await API_CLIENT.POST( - "/api/wired/set_ip_configuration", - { - body: payload, - }, - ); - - if (error) { - console.error("Error saving IP configuration:", error); - toast({ - title: "Error", - description: `Failed to save wired IP configuration: ${ - error.detail || "Unknown error" - }.`, - variant: "destructive", - }); - - updateIPConfiguration(); - } else { - toast({ - title: "Success", - description: "Wired IP configuration saved.", - variant: "default", - }); - // Re-fetch the configuration to show the new state after saving - updateIPConfiguration(); - } - } catch (e) { - console.error("API call error:", e); - toast({ - title: "Error", - description: "An unexpected error occurred while saving IP config.", - variant: "destructive", - }); - // Re-fetch on unexpected error as well - updateIPConfiguration(); - } finally { - setIsSaving(false); - } - }; - - useEffect(() => { - if (connected) { - socket?.on("ip_changed", () => updateIPConfiguration()); - updateIPConfiguration(); - } else { - // Clear configuration and reset form states when disconnected - setIpConfiguration(undefined); - setFormIpType(null); - setFormStaticIp(""); - setFormPrefix(null); - setFormGateway(""); - setFormDns(""); - } - }, [connected]); - - useEffect(() => { - if (ipConfiguration) { - setFormIpType(ipConfiguration.ip_type); - setFormStaticIp(ipConfiguration.static_ip || ""); - setFormPrefix(ipConfiguration.prefix ?? 24); - setFormGateway(ipConfiguration.gateway || ""); - setFormDns(ipConfiguration.dns ? ipConfiguration.dns.join(", ") : ""); - } else { - // If ipConfiguration becomes undefined, reset form states - setFormIpType(null); - setFormStaticIp(""); - setFormPrefix(null); - setFormGateway(""); - setFormDns(""); - } - }, [ipConfiguration]); - - return ( -
    - - - - - - Wired Network - - - {/* Display loading or "No ethernet" message if no config is available */} - {isLoading && !ipConfiguration ? ( // Show loading only if no config is loaded yet -
    - Loading configuration... -
    - ) : !ipConfiguration ? ( // Show no ethernet message if not loading and no config -
    - No wired network detected. -
    - ) : ( -
    -
    - - - setFormIpType(value as IPType) - } - className="flex space-x-4" - > -
    - - -
    -
    - - -
    -
    -
    - - {formIpType === "STATIC" && ( - <> -
    - - setFormStaticIp(e.target.value)} - placeholder="e.g., 192.168.1.100" - type="text" - /> -
    -
    - - { - const val = parseInt(e.target.value, 10); - setFormPrefix(isNaN(val) ? null : val); - }} - placeholder="e.g., 24" - type="number" - min={0} - max={32} - /> -
    -
    - - setFormGateway(e.target.value)} - placeholder="e.g., 192.168.1.1" - type="text" - /> -
    -
    - - setFormDns(e.target.value)} - placeholder="e.g., 8.8.8.8, 8.8.4.4" - type="text" - /> -
    - - )} - - {/* Save button */} - -
    - )} -
    -
    -
    - ); -} diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx index fc1666b7..e382b665 100644 --- a/frontend/src/components/ui/accordion.tsx +++ b/frontend/src/components/ui/accordion.tsx @@ -13,7 +13,7 @@ const AccordionItem = React.forwardRef< ) { + return ( +
    [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    [data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field data-[invalid=true]:text-destructive flex w-full gap-3", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
    + ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +