sp-rtk-base 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. sp_rtk_base/__init__.py +5 -0
  2. sp_rtk_base/api/__init__.py +3 -0
  3. sp_rtk_base/api/config.py +99 -0
  4. sp_rtk_base/api/destinations.py +161 -0
  5. sp_rtk_base/api/device.py +489 -0
  6. sp_rtk_base/api/events.py +74 -0
  7. sp_rtk_base/api/health.py +22 -0
  8. sp_rtk_base/api/metrics.py +67 -0
  9. sp_rtk_base/api/relay.py +135 -0
  10. sp_rtk_base/api/settings.py +124 -0
  11. sp_rtk_base/app.py +103 -0
  12. sp_rtk_base/cli/__init__.py +1 -0
  13. sp_rtk_base/cli/config_audit.py +601 -0
  14. sp_rtk_base/main.py +35 -0
  15. sp_rtk_base/models/__init__.py +3 -0
  16. sp_rtk_base/models/api_models.py +198 -0
  17. sp_rtk_base/models/config_models.py +300 -0
  18. sp_rtk_base/models/device_models.py +405 -0
  19. sp_rtk_base/py.typed +0 -0
  20. sp_rtk_base/services/__init__.py +123 -0
  21. sp_rtk_base/services/config_service.py +322 -0
  22. sp_rtk_base/services/device_service.py +473 -0
  23. sp_rtk_base/services/drivers/__init__.py +88 -0
  24. sp_rtk_base/services/drivers/base.py +296 -0
  25. sp_rtk_base/services/drivers/ublox.py +1029 -0
  26. sp_rtk_base/services/event_bridge.py +198 -0
  27. sp_rtk_base/services/metrics_service.py +182 -0
  28. sp_rtk_base/services/relay_service.py +233 -0
  29. sp_rtk_base/ui/__init__.py +3 -0
  30. sp_rtk_base/ui/components/__init__.py +7 -0
  31. sp_rtk_base/ui/components/status_card.py +37 -0
  32. sp_rtk_base/ui/layout.py +153 -0
  33. sp_rtk_base/ui/pages/__init__.py +3 -0
  34. sp_rtk_base/ui/pages/dashboard.py +412 -0
  35. sp_rtk_base/ui/pages/gps_config.py +647 -0
  36. sp_rtk_base/ui/pages/input.py +637 -0
  37. sp_rtk_base/ui/pages/outputs.py +402 -0
  38. sp_rtk_base/ui/pages/settings.py +111 -0
  39. sp_rtk_base/ui/pages/survey.py +1085 -0
  40. sp_rtk_base/ui/validators.py +109 -0
  41. sp_rtk_base-0.2.0.dist-info/METADATA +306 -0
  42. sp_rtk_base-0.2.0.dist-info/RECORD +44 -0
  43. sp_rtk_base-0.2.0.dist-info/WHEEL +4 -0
  44. sp_rtk_base-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,5 @@
1
+ """SP-Base: Web UI and API for u-blox GPS RTK base station relay control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ """SP-Base REST API endpoints."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,99 @@
1
+ """Config import/export API endpoints.
2
+
3
+ Provides YAML-based configuration export (download) and import (upload)
4
+ for backing up and restoring the full application configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ import yaml
12
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile
13
+ from fastapi.responses import Response
14
+
15
+ from sp_rtk_base.models.config_models import AppConfig
16
+ from sp_rtk_base.services import get_config_service
17
+ from sp_rtk_base.services.config_service import ConfigService
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ router = APIRouter(prefix="/api/config", tags=["config"])
22
+
23
+
24
+ @router.get("/export")
25
+ async def export_config(
26
+ config_svc: ConfigService = Depends(get_config_service),
27
+ ) -> Response:
28
+ """Export the full application configuration as a YAML file download.
29
+
30
+ Returns:
31
+ YAML file response with Content-Disposition attachment header.
32
+ """
33
+ config = config_svc.get_config()
34
+ data = config.model_dump(mode="json", exclude_none=True)
35
+ yaml_text = yaml.dump(data, default_flow_style=False, sort_keys=False)
36
+
37
+ return Response(
38
+ content=yaml_text,
39
+ media_type="application/x-yaml",
40
+ headers={"Content-Disposition": "attachment; filename=sp-rtk-base-config.yaml"},
41
+ )
42
+
43
+
44
+ @router.post("/import")
45
+ async def import_config(
46
+ file: UploadFile,
47
+ config_svc: ConfigService = Depends(get_config_service),
48
+ ) -> dict[str, str]:
49
+ """Import application configuration from a YAML file upload.
50
+
51
+ Validates the uploaded YAML against the AppConfig schema before saving.
52
+
53
+ Args:
54
+ file: The uploaded YAML configuration file.
55
+
56
+ Returns:
57
+ Success message with destination/input counts.
58
+
59
+ Raises:
60
+ HTTPException: 400 if the file is not valid YAML or fails schema validation.
61
+ """
62
+ try:
63
+ content = await file.read()
64
+ text = content.decode("utf-8")
65
+ except Exception as exc:
66
+ raise HTTPException(
67
+ status_code=400, detail=f"Failed to read uploaded file: {exc}"
68
+ ) from exc
69
+
70
+ if not text.strip():
71
+ raise HTTPException(status_code=400, detail="Uploaded file is empty")
72
+
73
+ try:
74
+ data = yaml.safe_load(text)
75
+ except yaml.YAMLError as exc:
76
+ raise HTTPException(status_code=400, detail=f"Invalid YAML: {exc}") from exc
77
+
78
+ if not isinstance(data, dict):
79
+ raise HTTPException(
80
+ status_code=400, detail="YAML content must be a mapping (object)"
81
+ )
82
+
83
+ try:
84
+ config = AppConfig.model_validate(data)
85
+ except Exception as exc:
86
+ raise HTTPException(
87
+ status_code=400, detail=f"Invalid configuration schema: {exc}"
88
+ ) from exc
89
+
90
+ config_svc.save_config(config)
91
+ logger.info("Configuration imported successfully")
92
+
93
+ return {
94
+ "status": "ok",
95
+ "message": (
96
+ f"Configuration imported: {len(config.destinations)} destinations, "
97
+ f"input={'configured' if config.input else 'none'}"
98
+ ),
99
+ }
@@ -0,0 +1,161 @@
1
+ """Destination management API endpoints.
2
+
3
+ Provides CRUD operations for relay destination profiles.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ from fastapi import APIRouter, Depends
11
+ from fastapi.responses import JSONResponse
12
+
13
+ from sp_rtk_base.models.api_models import (
14
+ DestinationCreateRequest,
15
+ DestinationListResponse,
16
+ DestinationResponse,
17
+ DestinationUpdateRequest,
18
+ RelayActionResponse,
19
+ )
20
+ from sp_rtk_base.models.config_models import DestinationProfile, FilterProfile
21
+ from sp_rtk_base.services import get_config_service
22
+ from sp_rtk_base.services.config_service import ConfigService
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ router = APIRouter(prefix="/api/destinations", tags=["destinations"])
27
+
28
+
29
+ def _profile_to_response(profile: DestinationProfile) -> DestinationResponse:
30
+ """Convert a DestinationProfile to an API response model."""
31
+ return DestinationResponse(
32
+ name=profile.name,
33
+ type=profile.type,
34
+ enabled=profile.enabled,
35
+ config=profile.config,
36
+ filter=profile.filter.model_dump(),
37
+ )
38
+
39
+
40
+ @router.get("", response_model=DestinationListResponse)
41
+ async def list_destinations(
42
+ config_svc: ConfigService = Depends(get_config_service),
43
+ ) -> DestinationListResponse:
44
+ """List all configured destinations."""
45
+ destinations = config_svc.get_destinations()
46
+ items = [_profile_to_response(d) for d in destinations]
47
+ return DestinationListResponse(destinations=items, count=len(items))
48
+
49
+
50
+ @router.get("/{name}", response_model=DestinationResponse)
51
+ async def get_destination(
52
+ name: str,
53
+ config_svc: ConfigService = Depends(get_config_service),
54
+ ) -> DestinationResponse | JSONResponse:
55
+ """Get a single destination by name."""
56
+ dest = config_svc.get_destination(name)
57
+ if dest is None:
58
+ return JSONResponse(
59
+ status_code=404,
60
+ content={"status": "error", "message": f"Destination '{name}' not found"},
61
+ )
62
+ return _profile_to_response(dest)
63
+
64
+
65
+ @router.post("", response_model=DestinationResponse, status_code=201)
66
+ async def create_destination(
67
+ request: DestinationCreateRequest,
68
+ config_svc: ConfigService = Depends(get_config_service),
69
+ ) -> DestinationResponse | JSONResponse:
70
+ """Create a new destination profile.
71
+
72
+ The destination is saved to config but not added to the running
73
+ relay engine. Restart the relay or use the relay start endpoint
74
+ to apply changes.
75
+ """
76
+ # Check for duplicate name
77
+ existing = config_svc.get_destination(request.name)
78
+ if existing is not None:
79
+ return JSONResponse(
80
+ status_code=409,
81
+ content={
82
+ "status": "error",
83
+ "message": f"Destination '{request.name}' already exists",
84
+ },
85
+ )
86
+
87
+ try:
88
+ profile = DestinationProfile(
89
+ name=request.name,
90
+ type=request.type, # type: ignore[arg-type]
91
+ enabled=request.enabled,
92
+ config=request.config,
93
+ filter=FilterProfile.model_validate(request.filter),
94
+ )
95
+ config_svc.save_destination(profile)
96
+ logger.info("Created destination: %s", request.name)
97
+ return _profile_to_response(profile)
98
+ except Exception as exc:
99
+ return JSONResponse(
100
+ status_code=400,
101
+ content={"status": "error", "message": str(exc)},
102
+ )
103
+
104
+
105
+ @router.put("/{name}", response_model=DestinationResponse)
106
+ async def update_destination(
107
+ name: str,
108
+ request: DestinationUpdateRequest,
109
+ config_svc: ConfigService = Depends(get_config_service),
110
+ ) -> DestinationResponse | JSONResponse:
111
+ """Update an existing destination profile.
112
+
113
+ Only the provided fields are updated; unset fields retain
114
+ their current values.
115
+ """
116
+ existing = config_svc.get_destination(name)
117
+ if existing is None:
118
+ return JSONResponse(
119
+ status_code=404,
120
+ content={"status": "error", "message": f"Destination '{name}' not found"},
121
+ )
122
+
123
+ try:
124
+ updated_data = existing.model_dump()
125
+ if request.enabled is not None:
126
+ updated_data["enabled"] = request.enabled
127
+ if request.config is not None:
128
+ updated_data["config"] = request.config
129
+ if request.filter is not None:
130
+ updated_data["filter"] = request.filter
131
+
132
+ updated = DestinationProfile.model_validate(updated_data)
133
+ config_svc.save_destination(updated)
134
+ logger.info("Updated destination: %s", name)
135
+ return _profile_to_response(updated)
136
+ except Exception as exc:
137
+ return JSONResponse(
138
+ status_code=400,
139
+ content={"status": "error", "message": str(exc)},
140
+ )
141
+
142
+
143
+ @router.delete("/{name}", response_model=RelayActionResponse)
144
+ async def delete_destination(
145
+ name: str,
146
+ config_svc: ConfigService = Depends(get_config_service),
147
+ ) -> RelayActionResponse | JSONResponse:
148
+ """Delete a destination profile.
149
+
150
+ Removes the destination from saved config. If the relay is
151
+ running, the destination continues until the relay is restarted.
152
+ """
153
+ removed = config_svc.remove_destination(name)
154
+ if not removed:
155
+ return JSONResponse(
156
+ status_code=404,
157
+ content={"status": "error", "message": f"Destination '{name}' not found"},
158
+ )
159
+
160
+ logger.info("Deleted destination: %s", name)
161
+ return RelayActionResponse(status="ok", message=f"Destination '{name}' deleted")