Skip to content

Commit 462064a

Browse files
committed
Merge branch 'Add-Multi-OpId-Search'
2 parents d809f1b + 5e11691 commit 462064a

14 files changed

Lines changed: 395 additions & 25 deletions

File tree

src/pypnm/api/routes/advance/common/abstract/multi_capture_router.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313

1414
from pypnm.api.routes.advance.common.abstract.service import AbstractService
1515
from pypnm.api.routes.advance.common.capture_service import AbstractCaptureService
16+
from pypnm.api.routes.advance.common.operation_kind import MultiCaptureOperation
1617
from pypnm.api.routes.advance.common.operation_manager import OperationManager
18+
from pypnm.api.routes.advance.common.schema.common_capture_schema import (
19+
MultiCaptureOperationIdResponse,
20+
)
1721
from pypnm.api.routes.common.classes.file_capture.capture_group import CaptureGroup
1822
from pypnm.config.system_config_settings import SystemConfigSettings
1923
from pypnm.lib.types import GroupId, OperationId
@@ -132,3 +136,8 @@ def _repair_capture_group_from_service_samples(self, operation_id: OperationId,
132136
exc,
133137
)
134138
return 0
139+
140+
def _build_operation_id_response(self, operation_name: MultiCaptureOperation) -> MultiCaptureOperationIdResponse:
141+
"""Return persisted operation records for a canonical multi-capture operation family."""
142+
operations = OperationManager.list_operation_records_by_name(operation_name.value)
143+
return MultiCaptureOperationIdResponse(status="success", message=None, operations=operations)

src/pypnm/api/routes/advance/common/capture_service.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66
import logging
77
import time
88
from abc import ABC, abstractmethod
9+
from enum import Enum
910
from typing import Any, cast
1011

12+
from pypnm.api.routes.advance.common.operation_kind import (
13+
MultiCaptureOperation,
14+
MultiCaptureOperationModel,
15+
)
1116
from pypnm.api.routes.advance.common.operation_manager import OperationManager
1217
from pypnm.api.routes.advance.common.operation_state import OperationState
1318
from pypnm.api.routes.common.classes.file_capture.capture_group import CaptureGroup
@@ -43,6 +48,9 @@ class AbstractCaptureService(ABC):
4348
logger (logging.Logger): Logger for operational messages.
4449
"""
4550

51+
OPERATION_NAME: MultiCaptureOperation = MultiCaptureOperation.MULTI_RXMER
52+
MEASURE_MODE: str = "standard"
53+
4654
def __init__(
4755
self,
4856
duration: float,
@@ -104,10 +112,11 @@ async def start(self) -> tuple[GroupId, OperationId]:
104112
operation_metadata["mac_address"] = str(mac_str)
105113
if self._has_populated_system_description(self._system_description):
106114
operation_metadata["system_description"] = dict(self._system_description)
115+
operation = self.get_operation_model()
107116

108117
try:
109118
om = OperationManager(capture_group_id=group_id)
110-
operation_id:OperationId = om.register(metadata=operation_metadata)
119+
operation_id:OperationId = om.register(operation=operation, metadata=operation_metadata)
111120
except Exception as exc:
112121
self.logger.error(f"Failed to create operation manager, reason={exc}", exc_info=True)
113122
raise
@@ -283,6 +292,10 @@ def get_system_description(self) -> dict[str, str]:
283292
"""Return the cached session-level sysDescr payload used for multi-capture metadata."""
284293
return dict(self._system_description)
285294

295+
def get_operation_model(self) -> MultiCaptureOperationModel:
296+
"""Return the persisted operation block associated with this capture service."""
297+
return MultiCaptureOperationModel(name=self.OPERATION_NAME, measure_mode=self._normalize_measure_mode(self.MEASURE_MODE))
298+
286299
def status(self, operation_id: OperationId) -> dict[str, Any]:
287300
"""
288301
Get the current state and sample count for a capture operation.
@@ -433,6 +446,13 @@ def _process_captures(self, msg_rsp: MessageResponse) -> list[CaptureSample]:
433446

434447
return samples
435448

449+
@staticmethod
450+
def _normalize_measure_mode(measure_mode: Enum | str) -> str:
451+
"""Normalize enum or string measure-mode values for persistence."""
452+
if isinstance(measure_mode, Enum):
453+
return measure_mode.name.lower()
454+
return str(measure_mode).strip().lower()
455+
436456
@abstractmethod
437457
async def _capture_message_response(self) -> MessageResponse:
438458
"""
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2026 Maurice Garcia
3+
from __future__ import annotations
4+
5+
from pydantic import BaseModel, Field
6+
7+
from pypnm.lib.types import StringEnum
8+
9+
10+
class MultiCaptureOperation(StringEnum):
11+
"""Canonical operation families for persisted multi-capture records."""
12+
13+
MULTI_RXMER = "multi_rxmer"
14+
MULTI_DS_CHANNEL_ESTIMATION = "multi_ds_channel_estimation"
15+
MULTI_US_OFDMA_PRE_EQUALIZATION = "multi_us_ofdma_pre_equalization"
16+
17+
18+
class MultiCaptureOperationModel(BaseModel):
19+
"""Persisted operation identity for replaying a completed multi-capture."""
20+
21+
name: MultiCaptureOperation = Field(..., description="Canonical multi-capture operation family name.")
22+
measure_mode: str = Field(..., description="Normalized measure mode name used when the operation was started.")

src/pypnm/api/routes/advance/common/operation_manager.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
from pathlib import Path
1010
from typing import Any
1111

12+
from pypnm.api.routes.advance.common.operation_kind import MultiCaptureOperationModel
13+
from pypnm.api.routes.advance.common.schema.common_capture_schema import (
14+
MultiCapturePersistedRecordModel,
15+
)
1216
from pypnm.config.system_config_settings import SystemConfigSettings
1317
from pypnm.lib.constants import cast
1418
from pypnm.lib.db.json_file_lock import JsonFileLock
@@ -27,7 +31,11 @@ class OperationManager:
2731
{
2832
"<operation_id>": {
2933
"capture_group_id": "<group_id>",
30-
"created": <unix_epoch_seconds>
34+
"created": <unix_epoch_seconds>,
35+
"operation": {
36+
"name": "multi_rxmer",
37+
"measure_mode": "ofdm_performance_1"
38+
}
3139
},
3240
...
3341
}
@@ -93,7 +101,11 @@ def _save(self, data: dict[str, Any]) -> None:
93101
except Exception as e:
94102
self.logger.error(f"Failed to save operation DB: {e}")
95103

96-
def register(self, metadata: dict[str, Any] | None = None) -> OperationId:
104+
def register(
105+
self,
106+
operation: MultiCaptureOperationModel | None = None,
107+
metadata: dict[str, Any] | None = None,
108+
) -> OperationId:
97109
"""
98110
Register this operation with its capture group ID in the DB.
99111
@@ -121,6 +133,8 @@ def register(self, metadata: dict[str, Any] | None = None) -> OperationId:
121133
"capture_group_id": self.capture_group_id,
122134
"created": int(time.time())
123135
}
136+
if operation is not None:
137+
db[self.operation_id]["operation"] = operation.model_dump()
124138
if isinstance(metadata, dict) and metadata:
125139
db[self.operation_id]["metadata"] = metadata
126140
self._save(db)
@@ -193,3 +207,49 @@ def get_operation_record(cls, operation_id: OperationId, db_path: Path | None =
193207
except Exception as e:
194208
logging.getLogger(cls.__name__).error(f"Error retrieving operation record for {operation_id}: {e}")
195209
return None
210+
211+
@classmethod
212+
def list_operation_records_by_name(
213+
cls,
214+
operation_name: str,
215+
db_path: Path | None = None,
216+
) -> dict[OperationId, MultiCapturePersistedRecordModel]:
217+
"""
218+
Return persisted operation records filtered by operation.name.
219+
220+
Args:
221+
operation_name: Canonical operation.name value to filter on.
222+
db_path: Optional override for the operations DB file.
223+
224+
Returns:
225+
Mapping of operation_id to typed persisted operation records.
226+
"""
227+
if not db_path:
228+
db_str = SystemConfigSettings.operation_db()
229+
db_path = Path(db_str)
230+
try:
231+
with JsonFileLock(db_path), db_path.open("r", encoding="utf-8") as f:
232+
db = json.load(f)
233+
except Exception as e:
234+
logging.getLogger(cls.__name__).error(f"Error listing operation records for {operation_name}: {e}")
235+
return {}
236+
237+
records: dict[OperationId, MultiCapturePersistedRecordModel] = {}
238+
for operation_id, record in db.items():
239+
if not isinstance(record, dict):
240+
continue
241+
operation = record.get("operation")
242+
if not isinstance(operation, dict):
243+
continue
244+
if operation.get("name") != operation_name:
245+
continue
246+
try:
247+
records[cast(OperationId, operation_id)] = MultiCapturePersistedRecordModel(**record)
248+
except Exception as e:
249+
logging.getLogger(cls.__name__).warning(
250+
"Skipping invalid operation record %s for %s: %s",
251+
operation_id,
252+
operation_name,
253+
e,
254+
)
255+
return records

src/pypnm/api/routes/advance/common/schema/common_capture_schema.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55

66
from pydantic import BaseModel, Field
77

8+
from pypnm.api.routes.advance.common.operation_kind import MultiCaptureOperationModel
89
from pypnm.api.routes.common.classes.common_endpoint_classes.common_req_resp import (
910
CableModemPnmConfig,
1011
)
1112
from pypnm.api.routes.common.classes.common_endpoint_classes.schema.base_response import (
1213
BaseDeviceResponse,
1314
)
14-
from pypnm.lib.types import OperationId
15+
from pypnm.lib.types import GroupId, MacAddressStr, OperationId, TimestampSec
1516

1617

1718
class CaptureParameters(BaseModel):
@@ -36,3 +37,26 @@ class MultiCaptureParametersResponse(BaseDeviceResponse):
3637
time_remaining: int = Field(..., description="Remaining time in seconds.")
3738
message: str | None = Field(default="", description="Optional human-readable message or error detail.")
3839

40+
41+
class MultiCapturePersistedMetadataModel(BaseModel):
42+
"""Known persisted metadata attached to a multi-capture operation record."""
43+
44+
mac_address: MacAddressStr | None = Field(default=None, description="Cable modem MAC address captured when the operation was started.")
45+
system_description: dict[str, str] = Field(default_factory=dict, description="Persisted system-description fields captured when the operation was started.")
46+
47+
48+
class MultiCapturePersistedRecordModel(BaseModel):
49+
"""Persisted multi-capture operation record stored by operation ID."""
50+
51+
capture_group_id: GroupId = Field(..., description="Capture-group identifier associated with the persisted operation.")
52+
created: TimestampSec = Field(..., description="Unix epoch seconds when the operation record was created.")
53+
operation: MultiCaptureOperationModel = Field(..., description="Canonical operation identity and measure-mode metadata.")
54+
metadata: MultiCapturePersistedMetadataModel = Field(default_factory=MultiCapturePersistedMetadataModel, description="Additional persisted metadata such as MAC address and system description.")
55+
56+
57+
class MultiCaptureOperationIdResponse(BaseModel):
58+
"""Collection of persisted operation records keyed by operation ID."""
59+
60+
status: str | None = Field(default="success", description="Overall status for the operation-record listing request.")
61+
message: str | None = Field(default=None, description="Additional information or error details for the operation-record listing request.")
62+
operations: dict[OperationId, MultiCapturePersistedRecordModel] = Field(default_factory=dict, description="Persisted operation records keyed by operation ID for the requested operation family.")

src/pypnm/api/routes/advance/multi_ds_chan_est/router.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
from pypnm.api.routes.advance.common.capture_data_aggregator import (
2121
CaptureDataAggregator,
2222
)
23+
from pypnm.api.routes.advance.common.operation_kind import MultiCaptureOperation
2324
from pypnm.api.routes.advance.common.operation_state import OperationState
25+
from pypnm.api.routes.advance.common.schema.common_capture_schema import (
26+
MultiCaptureOperationIdResponse,
27+
)
2428
from pypnm.api.routes.advance.multi_ds_chan_est.schemas import (
2529
AnalysisDataModel,
2630
MultiChanEstAnalysisRequest,
@@ -123,10 +127,11 @@ async def start_multi_chan_estimation(request: MultiChanEstRequest) -> MultiChan
123127
operation_id = operation_id)
124128

125129

126-
@self.router.get("/status/{operation_id}",
130+
@self.router.get("/status/{operationId}",
127131
response_model=MultiChanEstStatusResponse,
128132
summary="Get status of a multi-sample ChannelEstimation capture")
129-
def get_status(operation_id: OperationId) -> MultiChanEstStatusResponse:
133+
def get_status(operationId: OperationId) -> MultiChanEstStatusResponse:
134+
operation_id = operationId
130135
service: MultiChannelEstimationService = cast(
131136
MultiChannelEstimationService,
132137
self._get_service_or_404(operation_id),
@@ -145,23 +150,31 @@ def get_status(operation_id: OperationId) -> MultiChanEstStatusResponse:
145150
time_remaining = status["time_remaining"],
146151
message = None))
147152

148-
@self.router.get("/results/{operation_id}",
153+
@self.router.get("/operationId",
154+
response_model=MultiCaptureOperationIdResponse,
155+
summary="List persisted Multi-DS-Channel-Estimation operation IDs")
156+
def get_operation_ids() -> MultiCaptureOperationIdResponse:
157+
"""Return persisted operation records for Multi-DS-Channel-Estimation keyed by operation ID."""
158+
return self._build_operation_id_response(MultiCaptureOperation.MULTI_DS_CHANNEL_ESTIMATION)
159+
160+
@self.router.get("/results/{operationId}",
149161
summary="Download a ZIP archive of all ChannelEstimation capture files",
150162
responses={200: {"content": {"application/zip": {}},
151163
"description": "ZIP archive of capture files"}})
152-
def download_results_zip(operation_id: OperationId) -> StreamingResponse:
153-
164+
def download_results_zip(operationId: OperationId) -> StreamingResponse:
165+
operation_id = operationId
154166
return self._build_results_zip_response(operation_id, "multiChannelEstimation")
155167

156168

157-
@self.router.delete("/stop/{operation_id}",
169+
@self.router.delete("/stop/{operationId}",
158170
response_model=MultiChanEstStatusResponse,
159171
summary="Stop a running multi-sample ChannelEstimation capture early")
160-
def stop_capture(operation_id: OperationId) -> MultiChanEstStatusResponse:
172+
def stop_capture(operationId: OperationId) -> MultiChanEstStatusResponse:
161173
"""
162174
163175
164176
"""
177+
operation_id = operationId
165178
service: MultiChannelEstimationService = cast(
166179
MultiChannelEstimationService,
167180
self._get_service_or_404(operation_id),

src/pypnm/api/routes/advance/multi_ds_chan_est/service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66

77
from pypnm.api.routes.advance.common.capture_service import AbstractCaptureService
8+
from pypnm.api.routes.advance.common.operation_kind import MultiCaptureOperation
89
from pypnm.api.routes.common.extended.common_measure_schema import (
910
DownstreamOfdmParameters,
1011
)
@@ -35,6 +36,9 @@ class MultiChannelEstimationService(AbstractCaptureService):
3536
- duration: total measurement duration in seconds.
3637
- interval: interval between captures in seconds.
3738
"""
39+
OPERATION_NAME = MultiCaptureOperation.MULTI_DS_CHANNEL_ESTIMATION
40+
MEASURE_MODE = "standard"
41+
3842
def __init__(self, cm: CableModem,
3943
tftp_servers: tuple[Inet, Inet] = PnmConfigManager.get_tftp_servers(),
4044
tftp_path: str = PnmConfigManager.get_tftp_path(),

0 commit comments

Comments
 (0)