systemlink-cli 1.3.1__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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/asset_click.py
ADDED
|
@@ -0,0 +1,1289 @@
|
|
|
1
|
+
"""CLI commands for managing SystemLink assets.
|
|
2
|
+
|
|
3
|
+
Provides CLI commands for listing, querying, and managing assets in the
|
|
4
|
+
Asset Management service (niapm v1). Supports filtering by model, serial
|
|
5
|
+
number, bus type, asset type, calibration status, and connection state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import questionary
|
|
14
|
+
|
|
15
|
+
from .cli_utils import validate_output_format
|
|
16
|
+
from .universal_handlers import FilteredResponse, UniversalResponseHandler
|
|
17
|
+
from .utils import (
|
|
18
|
+
ExitCodes,
|
|
19
|
+
check_readonly_mode,
|
|
20
|
+
format_success,
|
|
21
|
+
get_base_url,
|
|
22
|
+
get_workspace_map,
|
|
23
|
+
handle_api_error,
|
|
24
|
+
make_api_request,
|
|
25
|
+
)
|
|
26
|
+
from .workspace_utils import (
|
|
27
|
+
get_effective_workspace,
|
|
28
|
+
get_workspace_display_name,
|
|
29
|
+
resolve_workspace_filter,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_asset_base_url() -> str:
|
|
34
|
+
"""Get the base URL for the Asset Management API."""
|
|
35
|
+
return f"{get_base_url()}/niapm/v1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _escape_filter_value(value: str) -> str:
|
|
39
|
+
"""Escape double quotes in filter values to prevent injection.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
value: Raw filter value from user input.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Escaped value safe for embedding in filter expressions.
|
|
46
|
+
"""
|
|
47
|
+
return value.replace('"', '\\"')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_properties(properties: Tuple[str, ...]) -> Dict[str, str]:
|
|
51
|
+
"""Parse key=value property strings into a dictionary.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
properties: Tuple of strings in "key=value" format.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dictionary mapping property keys to values.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
SystemExit: If any property string is not in key=value format.
|
|
61
|
+
"""
|
|
62
|
+
props_dict: Dict[str, str] = {}
|
|
63
|
+
for prop in properties:
|
|
64
|
+
if "=" not in prop:
|
|
65
|
+
click.echo(
|
|
66
|
+
f"✗ Invalid property format: {prop}. Use key=value",
|
|
67
|
+
err=True,
|
|
68
|
+
)
|
|
69
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
70
|
+
key, val = prop.split("=", 1)
|
|
71
|
+
props_dict[key.strip()] = val.strip()
|
|
72
|
+
return props_dict
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_asset_filter(
|
|
76
|
+
model: Optional[str] = None,
|
|
77
|
+
serial_number: Optional[str] = None,
|
|
78
|
+
bus_type: Optional[str] = None,
|
|
79
|
+
asset_type: Optional[str] = None,
|
|
80
|
+
calibration_status: Optional[str] = None,
|
|
81
|
+
connected: bool = False,
|
|
82
|
+
workspace_id: Optional[str] = None,
|
|
83
|
+
custom_filter: Optional[str] = None,
|
|
84
|
+
) -> Optional[str]:
|
|
85
|
+
"""Build API filter expression from convenience options.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
model: Filter by model name (contains match).
|
|
89
|
+
serial_number: Filter by serial number (exact match).
|
|
90
|
+
bus_type: Filter by bus type.
|
|
91
|
+
asset_type: Filter by asset type.
|
|
92
|
+
calibration_status: Filter by calibration status.
|
|
93
|
+
connected: Show only connected/present assets.
|
|
94
|
+
workspace_id: Filter by workspace ID.
|
|
95
|
+
custom_filter: Advanced user-provided filter expression.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Combined filter expression string, or None if no filters.
|
|
99
|
+
"""
|
|
100
|
+
parts: List[str] = []
|
|
101
|
+
|
|
102
|
+
if model:
|
|
103
|
+
escaped = _escape_filter_value(model)
|
|
104
|
+
parts.append(f'ModelName.Contains("{escaped}")')
|
|
105
|
+
if serial_number:
|
|
106
|
+
escaped = _escape_filter_value(serial_number)
|
|
107
|
+
parts.append(f'SerialNumber = "{escaped}"')
|
|
108
|
+
if bus_type:
|
|
109
|
+
parts.append(f'BusType = "{bus_type}"')
|
|
110
|
+
if asset_type:
|
|
111
|
+
parts.append(f'AssetType = "{asset_type}"')
|
|
112
|
+
if calibration_status:
|
|
113
|
+
parts.append(f'CalibrationStatus = "{calibration_status}"')
|
|
114
|
+
if connected:
|
|
115
|
+
parts.append(
|
|
116
|
+
'Location.AssetState.SystemConnection = "CONNECTED"'
|
|
117
|
+
' and Location.AssetState.AssetPresence = "PRESENT"'
|
|
118
|
+
)
|
|
119
|
+
if workspace_id:
|
|
120
|
+
escaped = _escape_filter_value(workspace_id)
|
|
121
|
+
parts.append(f'Workspace = "{escaped}"')
|
|
122
|
+
if custom_filter:
|
|
123
|
+
parts.append(custom_filter)
|
|
124
|
+
|
|
125
|
+
return " and ".join(parts) if parts else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _query_all_assets(
|
|
129
|
+
filter_expr: Optional[str],
|
|
130
|
+
order_by: Optional[str],
|
|
131
|
+
descending: bool,
|
|
132
|
+
take: Optional[int] = 10000,
|
|
133
|
+
calibratable_only: bool = False,
|
|
134
|
+
) -> List[Dict[str, Any]]:
|
|
135
|
+
"""Query assets using skip/take pagination.
|
|
136
|
+
|
|
137
|
+
Fetches up to ``take`` items (default 10,000 for performance).
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
filter_expr: Optional API filter expression.
|
|
141
|
+
order_by: Field to order by.
|
|
142
|
+
descending: Whether to return results in descending order.
|
|
143
|
+
take: Maximum number of items to fetch.
|
|
144
|
+
calibratable_only: Only return calibratable assets.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of asset objects (up to ``take`` count).
|
|
148
|
+
"""
|
|
149
|
+
url = f"{_get_asset_base_url()}/query-assets"
|
|
150
|
+
all_assets: List[Dict[str, Any]] = []
|
|
151
|
+
page_size = 1000 # API max per request
|
|
152
|
+
skip = 0
|
|
153
|
+
|
|
154
|
+
while True:
|
|
155
|
+
if take is not None:
|
|
156
|
+
remaining = take - len(all_assets)
|
|
157
|
+
if remaining <= 0:
|
|
158
|
+
break
|
|
159
|
+
batch_size = min(page_size, remaining)
|
|
160
|
+
else:
|
|
161
|
+
batch_size = page_size
|
|
162
|
+
|
|
163
|
+
payload: Dict[str, Any] = {
|
|
164
|
+
"skip": skip,
|
|
165
|
+
"take": batch_size,
|
|
166
|
+
"descending": descending,
|
|
167
|
+
"returnCount": True,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if filter_expr:
|
|
171
|
+
payload["filter"] = filter_expr
|
|
172
|
+
if order_by:
|
|
173
|
+
payload["orderBy"] = order_by
|
|
174
|
+
if calibratable_only:
|
|
175
|
+
payload["calibratableOnly"] = True
|
|
176
|
+
|
|
177
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
178
|
+
data = resp.json()
|
|
179
|
+
assets = data.get("assets", []) if isinstance(data, dict) else []
|
|
180
|
+
|
|
181
|
+
all_assets.extend(assets)
|
|
182
|
+
skip += len(assets)
|
|
183
|
+
|
|
184
|
+
# Stop if we got fewer than requested (last page)
|
|
185
|
+
if len(assets) < batch_size:
|
|
186
|
+
break
|
|
187
|
+
if take is not None and len(all_assets) >= take:
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
return all_assets[:take] if take is not None else all_assets
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fetch_assets_page(
|
|
194
|
+
filter_expr: Optional[str],
|
|
195
|
+
order_by: Optional[str],
|
|
196
|
+
descending: bool,
|
|
197
|
+
take: int,
|
|
198
|
+
skip: int,
|
|
199
|
+
calibratable_only: bool = False,
|
|
200
|
+
) -> Tuple[List[Dict[str, Any]], int]:
|
|
201
|
+
"""Fetch a single page of assets.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
filter_expr: Optional API filter expression.
|
|
205
|
+
order_by: Field to order by.
|
|
206
|
+
descending: Whether to return results in descending order.
|
|
207
|
+
take: Number of items to fetch.
|
|
208
|
+
skip: Number of items to skip.
|
|
209
|
+
calibratable_only: Only return calibratable assets.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Tuple of (assets list, total count from server).
|
|
213
|
+
"""
|
|
214
|
+
url = f"{_get_asset_base_url()}/query-assets"
|
|
215
|
+
payload: Dict[str, Any] = {
|
|
216
|
+
"skip": skip,
|
|
217
|
+
"take": take,
|
|
218
|
+
"descending": descending,
|
|
219
|
+
"returnCount": True,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if filter_expr:
|
|
223
|
+
payload["filter"] = filter_expr
|
|
224
|
+
if order_by:
|
|
225
|
+
payload["orderBy"] = order_by
|
|
226
|
+
if calibratable_only:
|
|
227
|
+
payload["calibratableOnly"] = True
|
|
228
|
+
|
|
229
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
230
|
+
data = resp.json()
|
|
231
|
+
|
|
232
|
+
assets = data.get("assets", []) if isinstance(data, dict) else []
|
|
233
|
+
total_count = data.get("totalCount", 0) if isinstance(data, dict) else 0
|
|
234
|
+
|
|
235
|
+
return assets, total_count
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _handle_asset_interactive_pagination(
|
|
239
|
+
filter_expr: Optional[str],
|
|
240
|
+
order_by: Optional[str],
|
|
241
|
+
descending: bool,
|
|
242
|
+
take: int,
|
|
243
|
+
calibratable_only: bool,
|
|
244
|
+
formatter_func: Any,
|
|
245
|
+
headers: List[str],
|
|
246
|
+
column_widths: List[int],
|
|
247
|
+
empty_message: str,
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Handle interactive skip/take pagination for table output.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
filter_expr: Optional API filter expression.
|
|
253
|
+
order_by: Field to order by.
|
|
254
|
+
descending: Whether to return results in descending order.
|
|
255
|
+
take: Number of items per page.
|
|
256
|
+
calibratable_only: Only return calibratable assets.
|
|
257
|
+
formatter_func: Function to format each item for display.
|
|
258
|
+
headers: Column headers for the table.
|
|
259
|
+
column_widths: Column widths for the table.
|
|
260
|
+
empty_message: Message to display when no items are found.
|
|
261
|
+
"""
|
|
262
|
+
skip = 0
|
|
263
|
+
shown_count = 0
|
|
264
|
+
|
|
265
|
+
while True:
|
|
266
|
+
page_items, total_count = _fetch_assets_page(
|
|
267
|
+
filter_expr, order_by, descending, take, skip, calibratable_only
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if not page_items:
|
|
271
|
+
if shown_count == 0:
|
|
272
|
+
click.echo(empty_message)
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
shown_count += len(page_items)
|
|
276
|
+
skip += len(page_items)
|
|
277
|
+
|
|
278
|
+
mock_resp: Any = FilteredResponse({"assets": page_items})
|
|
279
|
+
UniversalResponseHandler.handle_list_response(
|
|
280
|
+
resp=mock_resp,
|
|
281
|
+
data_key="assets",
|
|
282
|
+
item_name="asset",
|
|
283
|
+
format_output="table",
|
|
284
|
+
formatter_func=formatter_func,
|
|
285
|
+
headers=headers,
|
|
286
|
+
column_widths=column_widths,
|
|
287
|
+
empty_message=empty_message,
|
|
288
|
+
enable_pagination=False,
|
|
289
|
+
page_size=take,
|
|
290
|
+
total_count=total_count,
|
|
291
|
+
shown_count=shown_count,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Flush stdout so the table is visible before prompting
|
|
295
|
+
try:
|
|
296
|
+
sys.stdout.flush()
|
|
297
|
+
except Exception:
|
|
298
|
+
# stdout may be closed or invalid (e.g., when piped); ignore flush errors
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
# Check if there are more results
|
|
302
|
+
if shown_count >= total_count:
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
if not questionary.confirm("Show next set of results?", default=True).ask():
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _warn_if_large_dataset(
|
|
310
|
+
filter_expr: Optional[str],
|
|
311
|
+
calibratable_only: bool = False,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Check dataset size and warn user if fetching large number of items.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
filter_expr: Optional API filter expression.
|
|
317
|
+
calibratable_only: Only count calibratable assets.
|
|
318
|
+
"""
|
|
319
|
+
url = f"{_get_asset_base_url()}/query-assets"
|
|
320
|
+
payload: Dict[str, Any] = {
|
|
321
|
+
"skip": 0,
|
|
322
|
+
"take": 1,
|
|
323
|
+
"returnCount": True,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if filter_expr:
|
|
327
|
+
payload["filter"] = filter_expr
|
|
328
|
+
if calibratable_only:
|
|
329
|
+
payload["calibratableOnly"] = True
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
333
|
+
data = resp.json()
|
|
334
|
+
total_count = data.get("totalCount", 0) if isinstance(data, dict) else 0
|
|
335
|
+
|
|
336
|
+
if total_count > 10000:
|
|
337
|
+
click.echo(
|
|
338
|
+
f"⚠️ Warning: {total_count} items found. Fetching up to 10,000...",
|
|
339
|
+
err=True,
|
|
340
|
+
)
|
|
341
|
+
elif total_count > 1000:
|
|
342
|
+
click.echo(
|
|
343
|
+
f"ℹ️ Fetching {total_count} items...",
|
|
344
|
+
err=True,
|
|
345
|
+
)
|
|
346
|
+
except Exception:
|
|
347
|
+
# Best-effort warning: if we cannot determine total count
|
|
348
|
+
# (e.g., network error), continue without the size warning.
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _get_asset_location_display(asset: Dict[str, Any]) -> str:
|
|
353
|
+
"""Get a display string for an asset's location.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
asset: Asset dictionary.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Location display string.
|
|
360
|
+
"""
|
|
361
|
+
location = asset.get("location")
|
|
362
|
+
if not isinstance(location, dict):
|
|
363
|
+
return ""
|
|
364
|
+
|
|
365
|
+
minion_id = location.get("minionId", "")
|
|
366
|
+
physical = location.get("physicalLocation", "")
|
|
367
|
+
slot = location.get("slotNumber")
|
|
368
|
+
|
|
369
|
+
display = minion_id or physical
|
|
370
|
+
if slot is not None:
|
|
371
|
+
display = f"{display} (Slot {slot})" if display else f"Slot {slot}"
|
|
372
|
+
|
|
373
|
+
return display
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _format_asset_detail(asset: Dict[str, Any], workspace_map: Dict[str, str]) -> None:
|
|
377
|
+
"""Format and display detailed asset information.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
asset: Asset dictionary.
|
|
381
|
+
workspace_map: Workspace ID to name mapping.
|
|
382
|
+
"""
|
|
383
|
+
name = asset.get("name", "Unknown")
|
|
384
|
+
asset_id = asset.get("id", "")
|
|
385
|
+
click.echo(f"Asset: {name} ({asset_id})")
|
|
386
|
+
click.echo(f" Model: {asset.get('modelName', 'N/A')}")
|
|
387
|
+
click.echo(f" Serial Number: {asset.get('serialNumber', 'N/A')}")
|
|
388
|
+
click.echo(f" Part Number: {asset.get('partNumber', 'N/A')}")
|
|
389
|
+
click.echo(f" Vendor: {asset.get('vendorName', 'N/A')}")
|
|
390
|
+
click.echo(f" Bus Type: {asset.get('busType', 'N/A')}")
|
|
391
|
+
click.echo(f" Asset Type: {asset.get('assetType', 'N/A')}")
|
|
392
|
+
click.echo(f" Firmware: {asset.get('firmwareVersion', 'N/A')}")
|
|
393
|
+
click.echo(f" Hardware: {asset.get('hardwareVersion', 'N/A')}")
|
|
394
|
+
|
|
395
|
+
# Workspace
|
|
396
|
+
ws_id = asset.get("workspace", "")
|
|
397
|
+
ws_name = get_workspace_display_name(ws_id, workspace_map)
|
|
398
|
+
click.echo(f" Workspace: {ws_name} ({ws_id})")
|
|
399
|
+
|
|
400
|
+
# Location
|
|
401
|
+
location = asset.get("location")
|
|
402
|
+
if isinstance(location, dict):
|
|
403
|
+
loc_display = _get_asset_location_display(asset)
|
|
404
|
+
click.echo(f" Location: {loc_display}")
|
|
405
|
+
|
|
406
|
+
state = location.get("state")
|
|
407
|
+
if isinstance(state, dict):
|
|
408
|
+
click.echo(f" Presence: {state.get('assetPresence', 'N/A')}")
|
|
409
|
+
click.echo(f" System Connection: {state.get('systemConnection', 'N/A')}")
|
|
410
|
+
|
|
411
|
+
# Calibration
|
|
412
|
+
click.echo(f" Calibration Status: {asset.get('calibrationStatus', 'N/A')}")
|
|
413
|
+
|
|
414
|
+
ext_cal = asset.get("externalCalibration")
|
|
415
|
+
if isinstance(ext_cal, dict):
|
|
416
|
+
click.echo(f" Last Calibrated: {ext_cal.get('date', 'N/A')}")
|
|
417
|
+
click.echo(f" Next Due: {ext_cal.get('nextRecommendedDate', 'N/A')}")
|
|
418
|
+
|
|
419
|
+
# Keywords
|
|
420
|
+
keywords = asset.get("keywords")
|
|
421
|
+
if keywords and isinstance(keywords, list):
|
|
422
|
+
click.echo(f" Keywords: {', '.join(str(k) for k in keywords)}")
|
|
423
|
+
|
|
424
|
+
# Properties
|
|
425
|
+
properties = asset.get("properties")
|
|
426
|
+
if properties and isinstance(properties, dict):
|
|
427
|
+
click.echo(" Properties:")
|
|
428
|
+
for key, value in properties.items():
|
|
429
|
+
click.echo(f" {key}: {value}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def register_asset_commands(cli: Any) -> None:
|
|
433
|
+
"""Register the 'asset' command group and its subcommands.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
cli: Click CLI group to register commands on.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
@cli.group()
|
|
440
|
+
def asset() -> None:
|
|
441
|
+
"""Manage SystemLink assets.
|
|
442
|
+
|
|
443
|
+
Query, inspect, and manage hardware assets tracked by the Asset
|
|
444
|
+
Management service. Supports filtering by model, serial number, bus
|
|
445
|
+
type, calibration status, and connection state.
|
|
446
|
+
|
|
447
|
+
Filter syntax uses the Asset API expression language:
|
|
448
|
+
ModelName.Contains("PXI"), SerialNumber = "01BB877A",
|
|
449
|
+
BusType = "PCI_PXI", and/or operators.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
# Phase 1: list, get, summary
|
|
454
|
+
# ------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
@asset.command(name="list")
|
|
457
|
+
@click.option(
|
|
458
|
+
"--format",
|
|
459
|
+
"-f",
|
|
460
|
+
type=click.Choice(["table", "json"]),
|
|
461
|
+
default="table",
|
|
462
|
+
show_default=True,
|
|
463
|
+
help="Output format",
|
|
464
|
+
)
|
|
465
|
+
@click.option(
|
|
466
|
+
"--take",
|
|
467
|
+
"-t",
|
|
468
|
+
type=int,
|
|
469
|
+
default=25,
|
|
470
|
+
show_default=True,
|
|
471
|
+
help="Items per page (table output only)",
|
|
472
|
+
)
|
|
473
|
+
@click.option("--model", help="Filter by model name (contains match)")
|
|
474
|
+
@click.option("--serial-number", help="Filter by serial number (exact match)")
|
|
475
|
+
@click.option(
|
|
476
|
+
"--bus-type",
|
|
477
|
+
type=click.Choice(
|
|
478
|
+
["BUILT_IN_SYSTEM", "PCI_PXI", "USB", "GPIB", "VXI", "SERIAL", "TCP_IP", "CRIO"],
|
|
479
|
+
case_sensitive=True,
|
|
480
|
+
),
|
|
481
|
+
help="Filter by bus type",
|
|
482
|
+
)
|
|
483
|
+
@click.option(
|
|
484
|
+
"--asset-type",
|
|
485
|
+
type=click.Choice(
|
|
486
|
+
["GENERIC", "DEVICE_UNDER_TEST", "FIXTURE", "SYSTEM"],
|
|
487
|
+
case_sensitive=True,
|
|
488
|
+
),
|
|
489
|
+
help="Filter by asset type",
|
|
490
|
+
)
|
|
491
|
+
@click.option(
|
|
492
|
+
"--calibration-status",
|
|
493
|
+
type=click.Choice(
|
|
494
|
+
[
|
|
495
|
+
"OK",
|
|
496
|
+
"APPROACHING_RECOMMENDED_DUE_DATE",
|
|
497
|
+
"PAST_RECOMMENDED_DUE_DATE",
|
|
498
|
+
"OUT_FOR_CALIBRATION",
|
|
499
|
+
],
|
|
500
|
+
case_sensitive=True,
|
|
501
|
+
),
|
|
502
|
+
help="Filter by calibration status",
|
|
503
|
+
)
|
|
504
|
+
@click.option(
|
|
505
|
+
"--connected",
|
|
506
|
+
is_flag=True,
|
|
507
|
+
help="Show only assets in connected systems (CONNECTED + PRESENT)",
|
|
508
|
+
)
|
|
509
|
+
@click.option(
|
|
510
|
+
"--calibratable",
|
|
511
|
+
is_flag=True,
|
|
512
|
+
help="Show only calibratable assets",
|
|
513
|
+
)
|
|
514
|
+
@click.option("--workspace", "-w", help="Filter by workspace name or ID")
|
|
515
|
+
@click.option(
|
|
516
|
+
"--filter",
|
|
517
|
+
"filter_query",
|
|
518
|
+
help=(
|
|
519
|
+
"Advanced API filter expression "
|
|
520
|
+
'(e.g., \'ModelName.Contains("PXI") and BusType = "PCI_PXI"\')'
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
@click.option(
|
|
524
|
+
"--order-by",
|
|
525
|
+
type=click.Choice(["LAST_UPDATED_TIMESTAMP", "ID"], case_sensitive=False),
|
|
526
|
+
help="Order by field",
|
|
527
|
+
)
|
|
528
|
+
@click.option(
|
|
529
|
+
"--descending/--ascending",
|
|
530
|
+
default=True,
|
|
531
|
+
help="Sort order (default: descending)",
|
|
532
|
+
)
|
|
533
|
+
@click.option(
|
|
534
|
+
"--summary",
|
|
535
|
+
is_flag=True,
|
|
536
|
+
help="Show summary statistics instead of listing assets",
|
|
537
|
+
)
|
|
538
|
+
def list_assets(
|
|
539
|
+
format: str,
|
|
540
|
+
take: int,
|
|
541
|
+
model: Optional[str],
|
|
542
|
+
serial_number: Optional[str],
|
|
543
|
+
bus_type: Optional[str],
|
|
544
|
+
asset_type: Optional[str],
|
|
545
|
+
calibration_status: Optional[str],
|
|
546
|
+
connected: bool,
|
|
547
|
+
calibratable: bool,
|
|
548
|
+
workspace: Optional[str],
|
|
549
|
+
filter_query: Optional[str],
|
|
550
|
+
order_by: Optional[str],
|
|
551
|
+
descending: bool,
|
|
552
|
+
summary: bool,
|
|
553
|
+
) -> None:
|
|
554
|
+
"""List and query assets with optional filtering.
|
|
555
|
+
|
|
556
|
+
Supports convenience filters (--model, --serial-number, --bus-type,
|
|
557
|
+
etc.) that are translated to API filter expressions. Combine multiple
|
|
558
|
+
options — they are joined with 'and'.
|
|
559
|
+
|
|
560
|
+
For advanced queries use --filter with the Asset API filter syntax:
|
|
561
|
+
ModelName.Contains("PXI") and BusType = "PCI_PXI"
|
|
562
|
+
"""
|
|
563
|
+
format_output = validate_output_format(format)
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
# Resolve workspace if provided
|
|
567
|
+
workspace_id: Optional[str] = None
|
|
568
|
+
try:
|
|
569
|
+
workspace_map = get_workspace_map()
|
|
570
|
+
except Exception:
|
|
571
|
+
workspace_map = {}
|
|
572
|
+
|
|
573
|
+
workspace = get_effective_workspace(workspace)
|
|
574
|
+
if workspace:
|
|
575
|
+
workspace_id = resolve_workspace_filter(workspace, workspace_map)
|
|
576
|
+
|
|
577
|
+
filter_expr = _build_asset_filter(
|
|
578
|
+
model=model,
|
|
579
|
+
serial_number=serial_number,
|
|
580
|
+
bus_type=bus_type,
|
|
581
|
+
asset_type=asset_type,
|
|
582
|
+
calibration_status=calibration_status,
|
|
583
|
+
connected=connected,
|
|
584
|
+
workspace_id=workspace_id,
|
|
585
|
+
custom_filter=filter_query,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
if order_by:
|
|
589
|
+
order_by = order_by.upper()
|
|
590
|
+
|
|
591
|
+
def asset_formatter(item: Dict[str, Any]) -> List[str]:
|
|
592
|
+
ws_id = item.get("workspace", "")
|
|
593
|
+
ws_name = get_workspace_display_name(ws_id, workspace_map)
|
|
594
|
+
return [
|
|
595
|
+
item.get("name", ""),
|
|
596
|
+
item.get("modelName", ""),
|
|
597
|
+
item.get("serialNumber", ""),
|
|
598
|
+
item.get("busType", ""),
|
|
599
|
+
item.get("calibrationStatus", ""),
|
|
600
|
+
_get_asset_location_display(item),
|
|
601
|
+
ws_name,
|
|
602
|
+
item.get("id", ""),
|
|
603
|
+
]
|
|
604
|
+
|
|
605
|
+
headers = [
|
|
606
|
+
"Name",
|
|
607
|
+
"Model",
|
|
608
|
+
"Serial Number",
|
|
609
|
+
"Bus Type",
|
|
610
|
+
"Calibration",
|
|
611
|
+
"Location",
|
|
612
|
+
"Workspace",
|
|
613
|
+
"ID",
|
|
614
|
+
]
|
|
615
|
+
column_widths = [24, 20, 16, 12, 16, 16, 16, 36]
|
|
616
|
+
|
|
617
|
+
if format_output.lower() == "json":
|
|
618
|
+
_warn_if_large_dataset(filter_expr, calibratable)
|
|
619
|
+
assets = _query_all_assets(
|
|
620
|
+
filter_expr, order_by, descending, calibratable_only=calibratable
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if summary:
|
|
624
|
+
summary_stats = _summarize_assets(assets)
|
|
625
|
+
click.echo(json.dumps(summary_stats, indent=2))
|
|
626
|
+
else:
|
|
627
|
+
mock_resp: Any = FilteredResponse({"assets": assets})
|
|
628
|
+
UniversalResponseHandler.handle_list_response(
|
|
629
|
+
resp=mock_resp,
|
|
630
|
+
data_key="assets",
|
|
631
|
+
item_name="asset",
|
|
632
|
+
format_output=format_output,
|
|
633
|
+
formatter_func=asset_formatter,
|
|
634
|
+
headers=headers,
|
|
635
|
+
column_widths=column_widths,
|
|
636
|
+
empty_message="No assets found.",
|
|
637
|
+
enable_pagination=False,
|
|
638
|
+
page_size=take,
|
|
639
|
+
)
|
|
640
|
+
else:
|
|
641
|
+
if summary:
|
|
642
|
+
_warn_if_large_dataset(filter_expr, calibratable)
|
|
643
|
+
all_assets = _query_all_assets(
|
|
644
|
+
filter_expr, order_by, descending, calibratable_only=calibratable
|
|
645
|
+
)
|
|
646
|
+
summary_stats = _summarize_assets(all_assets)
|
|
647
|
+
click.echo("\nAsset Summary Statistics:")
|
|
648
|
+
click.echo(f" Total Assets: {summary_stats['total']}")
|
|
649
|
+
click.echo(
|
|
650
|
+
f" Bus Types: {', '.join(summary_stats.get('busTypes', {}).keys()) or 'N/A'}"
|
|
651
|
+
)
|
|
652
|
+
if summary_stats.get("truncated"):
|
|
653
|
+
click.echo(f" Note: {summary_stats['note']}", err=True)
|
|
654
|
+
click.echo()
|
|
655
|
+
else:
|
|
656
|
+
_handle_asset_interactive_pagination(
|
|
657
|
+
filter_expr=filter_expr,
|
|
658
|
+
order_by=order_by,
|
|
659
|
+
descending=descending,
|
|
660
|
+
take=take,
|
|
661
|
+
calibratable_only=calibratable,
|
|
662
|
+
formatter_func=asset_formatter,
|
|
663
|
+
headers=headers,
|
|
664
|
+
column_widths=column_widths,
|
|
665
|
+
empty_message="No assets found.",
|
|
666
|
+
)
|
|
667
|
+
except Exception as exc: # noqa: BLE001
|
|
668
|
+
handle_api_error(exc)
|
|
669
|
+
|
|
670
|
+
@asset.command(name="get")
|
|
671
|
+
@click.argument("asset_id")
|
|
672
|
+
@click.option(
|
|
673
|
+
"--format",
|
|
674
|
+
"-f",
|
|
675
|
+
type=click.Choice(["table", "json"]),
|
|
676
|
+
default="table",
|
|
677
|
+
show_default=True,
|
|
678
|
+
help="Output format",
|
|
679
|
+
)
|
|
680
|
+
@click.option(
|
|
681
|
+
"--include-calibration",
|
|
682
|
+
is_flag=True,
|
|
683
|
+
help="Include calibration history in output",
|
|
684
|
+
)
|
|
685
|
+
def get_asset(
|
|
686
|
+
asset_id: str,
|
|
687
|
+
format: str,
|
|
688
|
+
include_calibration: bool,
|
|
689
|
+
) -> None:
|
|
690
|
+
"""Get detailed information about a specific asset.
|
|
691
|
+
|
|
692
|
+
ASSET_ID is the unique identifier of the asset.
|
|
693
|
+
"""
|
|
694
|
+
format_output = validate_output_format(format)
|
|
695
|
+
|
|
696
|
+
try:
|
|
697
|
+
url = f"{_get_asset_base_url()}/assets/{asset_id}"
|
|
698
|
+
resp = make_api_request("GET", url)
|
|
699
|
+
asset_data = resp.json()
|
|
700
|
+
|
|
701
|
+
# Optionally fetch calibration history
|
|
702
|
+
if include_calibration:
|
|
703
|
+
try:
|
|
704
|
+
cal_url = f"{_get_asset_base_url()}/assets/{asset_id}/history/calibration"
|
|
705
|
+
cal_resp = make_api_request("GET", cal_url)
|
|
706
|
+
cal_data = cal_resp.json()
|
|
707
|
+
cal_entries = (
|
|
708
|
+
cal_data.get("calibrationHistory", []) if isinstance(cal_data, dict) else []
|
|
709
|
+
)
|
|
710
|
+
asset_data["calibrationHistory"] = cal_entries
|
|
711
|
+
except Exception:
|
|
712
|
+
asset_data["calibrationHistory"] = []
|
|
713
|
+
|
|
714
|
+
if format_output.lower() == "json":
|
|
715
|
+
click.echo(json.dumps(asset_data, indent=2))
|
|
716
|
+
else:
|
|
717
|
+
try:
|
|
718
|
+
workspace_map = get_workspace_map()
|
|
719
|
+
except Exception:
|
|
720
|
+
workspace_map = {}
|
|
721
|
+
_format_asset_detail(asset_data, workspace_map)
|
|
722
|
+
|
|
723
|
+
if include_calibration and asset_data.get("calibrationHistory"):
|
|
724
|
+
click.echo("\nCalibration History:")
|
|
725
|
+
for entry in asset_data["calibrationHistory"]:
|
|
726
|
+
date = entry.get("date", "N/A")
|
|
727
|
+
entry_type = entry.get("entryType", "N/A")
|
|
728
|
+
click.echo(f" {date} — {entry_type}")
|
|
729
|
+
|
|
730
|
+
except Exception as exc: # noqa: BLE001
|
|
731
|
+
handle_api_error(exc)
|
|
732
|
+
|
|
733
|
+
@asset.command(name="summary")
|
|
734
|
+
@click.option(
|
|
735
|
+
"--format",
|
|
736
|
+
"-f",
|
|
737
|
+
type=click.Choice(["table", "json"]),
|
|
738
|
+
default="table",
|
|
739
|
+
show_default=True,
|
|
740
|
+
help="Output format",
|
|
741
|
+
)
|
|
742
|
+
def asset_summary(format: str) -> None:
|
|
743
|
+
"""Show fleet-wide asset summary statistics.
|
|
744
|
+
|
|
745
|
+
Displays counts for total, active, in-use assets and calibration
|
|
746
|
+
status breakdown.
|
|
747
|
+
"""
|
|
748
|
+
format_output = validate_output_format(format)
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
url = f"{_get_asset_base_url()}/asset-summary"
|
|
752
|
+
resp = make_api_request("GET", url)
|
|
753
|
+
data = resp.json()
|
|
754
|
+
|
|
755
|
+
if format_output.lower() == "json":
|
|
756
|
+
click.echo(json.dumps(data, indent=2))
|
|
757
|
+
else:
|
|
758
|
+
click.echo("\nAsset Fleet Summary:")
|
|
759
|
+
click.echo(f" Total Assets: {data.get('total', 0)}")
|
|
760
|
+
click.echo(f" Active (in connected system): {data.get('active', 0)}")
|
|
761
|
+
click.echo(f" Not Active: {data.get('notActive', 0)}")
|
|
762
|
+
click.echo(f" In Use: {data.get('inUse', 0)}")
|
|
763
|
+
click.echo(f" Not In Use: {data.get('notInUse', 0)}")
|
|
764
|
+
click.echo(f" With Alarms: {data.get('withAlarms', 0)}")
|
|
765
|
+
click.echo("\nCalibration Status:")
|
|
766
|
+
click.echo(
|
|
767
|
+
f" Approaching Due Date: " f"{data.get('approachingRecommendedDueDate', 0)}"
|
|
768
|
+
)
|
|
769
|
+
click.echo(f" Past Due Date: {data.get('pastRecommendedDueDate', 0)}")
|
|
770
|
+
click.echo(f" Out for Calibration: {data.get('outForCalibration', 0)}")
|
|
771
|
+
click.echo(f" Total Calibratable: {data.get('totalCalibrated', 0)}")
|
|
772
|
+
click.echo()
|
|
773
|
+
|
|
774
|
+
except Exception as exc: # noqa: BLE001
|
|
775
|
+
handle_api_error(exc)
|
|
776
|
+
|
|
777
|
+
# ------------------------------------------------------------------
|
|
778
|
+
# Phase 2: calibration, location-history
|
|
779
|
+
# ------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
@asset.command(name="calibration")
|
|
782
|
+
@click.argument("asset_id")
|
|
783
|
+
@click.option(
|
|
784
|
+
"--format",
|
|
785
|
+
"-f",
|
|
786
|
+
type=click.Choice(["table", "json"]),
|
|
787
|
+
default="table",
|
|
788
|
+
show_default=True,
|
|
789
|
+
help="Output format",
|
|
790
|
+
)
|
|
791
|
+
@click.option(
|
|
792
|
+
"--take",
|
|
793
|
+
"-t",
|
|
794
|
+
type=int,
|
|
795
|
+
default=25,
|
|
796
|
+
show_default=True,
|
|
797
|
+
help="Number of history entries to return",
|
|
798
|
+
)
|
|
799
|
+
def asset_calibration(
|
|
800
|
+
asset_id: str,
|
|
801
|
+
format: str,
|
|
802
|
+
take: int,
|
|
803
|
+
) -> None:
|
|
804
|
+
"""Get calibration history for a specific asset.
|
|
805
|
+
|
|
806
|
+
ASSET_ID is the unique identifier of the asset.
|
|
807
|
+
"""
|
|
808
|
+
format_output = validate_output_format(format)
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
url = (
|
|
812
|
+
f"{_get_asset_base_url()}/assets/{asset_id}/history/calibration"
|
|
813
|
+
f"?Skip=0&Take={take}"
|
|
814
|
+
)
|
|
815
|
+
resp = make_api_request("GET", url)
|
|
816
|
+
data = resp.json()
|
|
817
|
+
|
|
818
|
+
entries = data.get("calibrationHistory", []) if isinstance(data, dict) else []
|
|
819
|
+
|
|
820
|
+
def calibration_formatter(item: Dict[str, Any]) -> List[str]:
|
|
821
|
+
return [
|
|
822
|
+
item.get("date", ""),
|
|
823
|
+
item.get("entryType", ""),
|
|
824
|
+
str(item.get("isLimited", "")),
|
|
825
|
+
item.get("resolvedDueDate", ""),
|
|
826
|
+
str(item.get("recommendedInterval", "")),
|
|
827
|
+
item.get("comments", ""),
|
|
828
|
+
]
|
|
829
|
+
|
|
830
|
+
if format_output.lower() == "json":
|
|
831
|
+
click.echo(json.dumps(entries, indent=2))
|
|
832
|
+
else:
|
|
833
|
+
mock_resp: Any = FilteredResponse({"calibrationHistory": entries})
|
|
834
|
+
UniversalResponseHandler.handle_list_response(
|
|
835
|
+
resp=mock_resp,
|
|
836
|
+
data_key="calibrationHistory",
|
|
837
|
+
item_name="calibration entry",
|
|
838
|
+
format_output=format_output,
|
|
839
|
+
formatter_func=calibration_formatter,
|
|
840
|
+
headers=[
|
|
841
|
+
"Date",
|
|
842
|
+
"Type",
|
|
843
|
+
"Limited",
|
|
844
|
+
"Next Due",
|
|
845
|
+
"Interval (mo)",
|
|
846
|
+
"Comments",
|
|
847
|
+
],
|
|
848
|
+
column_widths=[20, 12, 8, 20, 14, 30],
|
|
849
|
+
empty_message="No calibration history found.",
|
|
850
|
+
enable_pagination=True,
|
|
851
|
+
page_size=take,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
except Exception as exc: # noqa: BLE001
|
|
855
|
+
handle_api_error(exc)
|
|
856
|
+
|
|
857
|
+
@asset.command(name="location-history")
|
|
858
|
+
@click.argument("asset_id")
|
|
859
|
+
@click.option(
|
|
860
|
+
"--format",
|
|
861
|
+
"-f",
|
|
862
|
+
type=click.Choice(["table", "json"]),
|
|
863
|
+
default="table",
|
|
864
|
+
show_default=True,
|
|
865
|
+
help="Output format",
|
|
866
|
+
)
|
|
867
|
+
@click.option(
|
|
868
|
+
"--take",
|
|
869
|
+
"-t",
|
|
870
|
+
type=int,
|
|
871
|
+
default=25,
|
|
872
|
+
show_default=True,
|
|
873
|
+
help="Number of history entries to return",
|
|
874
|
+
)
|
|
875
|
+
@click.option(
|
|
876
|
+
"--from",
|
|
877
|
+
"date_from",
|
|
878
|
+
type=str,
|
|
879
|
+
default=None,
|
|
880
|
+
help="Start of date range (ISO-8601, e.g., 2025-12-01T00:00:00Z)",
|
|
881
|
+
)
|
|
882
|
+
@click.option(
|
|
883
|
+
"--to",
|
|
884
|
+
"date_to",
|
|
885
|
+
type=str,
|
|
886
|
+
default=None,
|
|
887
|
+
help="End of date range (ISO-8601, e.g., 2025-12-02T00:00:00Z)",
|
|
888
|
+
)
|
|
889
|
+
def asset_location_history(
|
|
890
|
+
asset_id: str,
|
|
891
|
+
format: str,
|
|
892
|
+
take: int,
|
|
893
|
+
date_from: Optional[str],
|
|
894
|
+
date_to: Optional[str],
|
|
895
|
+
) -> None:
|
|
896
|
+
"""Get location/connection history for a specific asset.
|
|
897
|
+
|
|
898
|
+
ASSET_ID is the unique identifier of the asset.
|
|
899
|
+
|
|
900
|
+
Use --from and --to for temporal correlation (e.g., confirming an
|
|
901
|
+
asset was present in a system at the time of a test).
|
|
902
|
+
"""
|
|
903
|
+
format_output = validate_output_format(format)
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
url = f"{_get_asset_base_url()}/assets/{asset_id}/history/query-location"
|
|
907
|
+
payload: Dict[str, Any] = {
|
|
908
|
+
"skip": 0,
|
|
909
|
+
"take": take,
|
|
910
|
+
}
|
|
911
|
+
if date_from:
|
|
912
|
+
payload["startTimestamp"] = date_from
|
|
913
|
+
if date_to:
|
|
914
|
+
payload["endTimestamp"] = date_to
|
|
915
|
+
|
|
916
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
917
|
+
data = resp.json()
|
|
918
|
+
|
|
919
|
+
entries = data.get("connectionHistory", []) if isinstance(data, dict) else []
|
|
920
|
+
|
|
921
|
+
def location_formatter(item: Dict[str, Any]) -> List[str]:
|
|
922
|
+
return [
|
|
923
|
+
item.get("timestamp", ""),
|
|
924
|
+
item.get("minionId", ""),
|
|
925
|
+
str(item.get("slotNumber", "")),
|
|
926
|
+
item.get("systemConnection", ""),
|
|
927
|
+
item.get("assetPresence", ""),
|
|
928
|
+
]
|
|
929
|
+
|
|
930
|
+
if format_output.lower() == "json":
|
|
931
|
+
click.echo(json.dumps(entries, indent=2))
|
|
932
|
+
else:
|
|
933
|
+
mock_resp: Any = FilteredResponse({"connectionHistory": entries})
|
|
934
|
+
UniversalResponseHandler.handle_list_response(
|
|
935
|
+
resp=mock_resp,
|
|
936
|
+
data_key="connectionHistory",
|
|
937
|
+
item_name="location entry",
|
|
938
|
+
format_output=format_output,
|
|
939
|
+
formatter_func=location_formatter,
|
|
940
|
+
headers=["Timestamp", "Minion ID", "Slot", "Connection", "Presence"],
|
|
941
|
+
column_widths=[24, 30, 6, 14, 12],
|
|
942
|
+
empty_message="No location history found.",
|
|
943
|
+
enable_pagination=True,
|
|
944
|
+
page_size=take,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
except Exception as exc: # noqa: BLE001
|
|
948
|
+
handle_api_error(exc)
|
|
949
|
+
|
|
950
|
+
# ------------------------------------------------------------------
|
|
951
|
+
# Phase 3: create, update, delete (mutations)
|
|
952
|
+
# ------------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
@asset.command(name="create")
|
|
955
|
+
@click.option("--model-name", required=True, help="Model name of the asset")
|
|
956
|
+
@click.option("--model-number", type=int, default=None, help="Model number")
|
|
957
|
+
@click.option("--serial-number", default=None, help="Serial number")
|
|
958
|
+
@click.option("--vendor-name", default=None, help="Vendor name")
|
|
959
|
+
@click.option("--vendor-number", type=int, default=None, help="Vendor number")
|
|
960
|
+
@click.option("--part-number", default=None, help="Part number")
|
|
961
|
+
@click.option("--name", "asset_name", default=None, help="Display name for the asset")
|
|
962
|
+
@click.option(
|
|
963
|
+
"--bus-type",
|
|
964
|
+
type=click.Choice(
|
|
965
|
+
["BUILT_IN_SYSTEM", "PCI_PXI", "USB", "GPIB", "VXI", "SERIAL", "TCP_IP", "CRIO"],
|
|
966
|
+
case_sensitive=True,
|
|
967
|
+
),
|
|
968
|
+
default=None,
|
|
969
|
+
help="Bus type",
|
|
970
|
+
)
|
|
971
|
+
@click.option(
|
|
972
|
+
"--asset-type",
|
|
973
|
+
type=click.Choice(
|
|
974
|
+
["GENERIC", "DEVICE_UNDER_TEST", "FIXTURE", "SYSTEM"],
|
|
975
|
+
case_sensitive=True,
|
|
976
|
+
),
|
|
977
|
+
default=None,
|
|
978
|
+
help="Asset type",
|
|
979
|
+
)
|
|
980
|
+
@click.option("--firmware-version", default=None, help="Firmware version")
|
|
981
|
+
@click.option("--hardware-version", default=None, help="Hardware version")
|
|
982
|
+
@click.option("--workspace", "-w", default=None, help="Workspace name or ID")
|
|
983
|
+
@click.option(
|
|
984
|
+
"--keyword",
|
|
985
|
+
"keywords",
|
|
986
|
+
multiple=True,
|
|
987
|
+
help="Keyword to associate (repeatable)",
|
|
988
|
+
)
|
|
989
|
+
@click.option(
|
|
990
|
+
"--property",
|
|
991
|
+
"properties",
|
|
992
|
+
multiple=True,
|
|
993
|
+
help="Property in key=value format (repeatable)",
|
|
994
|
+
)
|
|
995
|
+
@click.option(
|
|
996
|
+
"--format",
|
|
997
|
+
"-f",
|
|
998
|
+
type=click.Choice(["table", "json"]),
|
|
999
|
+
default="table",
|
|
1000
|
+
show_default=True,
|
|
1001
|
+
help="Output format",
|
|
1002
|
+
)
|
|
1003
|
+
def create_asset(
|
|
1004
|
+
model_name: str,
|
|
1005
|
+
model_number: Optional[int],
|
|
1006
|
+
serial_number: Optional[str],
|
|
1007
|
+
vendor_name: Optional[str],
|
|
1008
|
+
vendor_number: Optional[int],
|
|
1009
|
+
part_number: Optional[str],
|
|
1010
|
+
asset_name: Optional[str],
|
|
1011
|
+
bus_type: Optional[str],
|
|
1012
|
+
asset_type: Optional[str],
|
|
1013
|
+
firmware_version: Optional[str],
|
|
1014
|
+
hardware_version: Optional[str],
|
|
1015
|
+
workspace: Optional[str],
|
|
1016
|
+
keywords: Tuple[str, ...],
|
|
1017
|
+
properties: Tuple[str, ...],
|
|
1018
|
+
format: str,
|
|
1019
|
+
) -> None:
|
|
1020
|
+
"""Create a new asset.
|
|
1021
|
+
|
|
1022
|
+
Requires at minimum a --model-name. Additional fields can be set
|
|
1023
|
+
via options.
|
|
1024
|
+
"""
|
|
1025
|
+
check_readonly_mode("create an asset")
|
|
1026
|
+
format_output = validate_output_format(format)
|
|
1027
|
+
|
|
1028
|
+
try:
|
|
1029
|
+
asset_data: Dict[str, Any] = {
|
|
1030
|
+
"modelName": model_name,
|
|
1031
|
+
"location": {
|
|
1032
|
+
"state": {"assetPresence": "UNKNOWN"},
|
|
1033
|
+
},
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if model_number:
|
|
1037
|
+
asset_data["modelNumber"] = model_number
|
|
1038
|
+
if serial_number:
|
|
1039
|
+
asset_data["serialNumber"] = serial_number
|
|
1040
|
+
if vendor_name:
|
|
1041
|
+
asset_data["vendorName"] = vendor_name
|
|
1042
|
+
if vendor_number:
|
|
1043
|
+
asset_data["vendorNumber"] = vendor_number
|
|
1044
|
+
if part_number:
|
|
1045
|
+
asset_data["partNumber"] = part_number
|
|
1046
|
+
if asset_name:
|
|
1047
|
+
asset_data["name"] = asset_name
|
|
1048
|
+
if bus_type:
|
|
1049
|
+
asset_data["busType"] = bus_type
|
|
1050
|
+
if asset_type:
|
|
1051
|
+
asset_data["assetType"] = asset_type
|
|
1052
|
+
if firmware_version:
|
|
1053
|
+
asset_data["firmwareVersion"] = firmware_version
|
|
1054
|
+
if hardware_version:
|
|
1055
|
+
asset_data["hardwareVersion"] = hardware_version
|
|
1056
|
+
|
|
1057
|
+
# Resolve workspace
|
|
1058
|
+
workspace = get_effective_workspace(workspace)
|
|
1059
|
+
if workspace:
|
|
1060
|
+
try:
|
|
1061
|
+
ws_map = get_workspace_map()
|
|
1062
|
+
ws_id = resolve_workspace_filter(workspace, ws_map)
|
|
1063
|
+
asset_data["workspace"] = ws_id
|
|
1064
|
+
except Exception:
|
|
1065
|
+
asset_data["workspace"] = workspace
|
|
1066
|
+
|
|
1067
|
+
if keywords:
|
|
1068
|
+
asset_data["keywords"] = list(keywords)
|
|
1069
|
+
|
|
1070
|
+
if properties:
|
|
1071
|
+
asset_data["properties"] = _parse_properties(properties)
|
|
1072
|
+
|
|
1073
|
+
url = f"{_get_asset_base_url()}/assets"
|
|
1074
|
+
payload: Dict[str, Any] = {"assets": [asset_data]}
|
|
1075
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
1076
|
+
result_data = resp.json()
|
|
1077
|
+
|
|
1078
|
+
# Check if creation was successful
|
|
1079
|
+
assets = result_data.get("assets", [])
|
|
1080
|
+
failed = result_data.get("failed", [])
|
|
1081
|
+
|
|
1082
|
+
if format_output.lower() == "json":
|
|
1083
|
+
click.echo(json.dumps(result_data, indent=2))
|
|
1084
|
+
# Exit with error if any assets failed (complete or partial failure)
|
|
1085
|
+
if failed:
|
|
1086
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1087
|
+
else:
|
|
1088
|
+
if assets and failed:
|
|
1089
|
+
# Partial success: show success but warn about failures
|
|
1090
|
+
format_success(
|
|
1091
|
+
"Asset created",
|
|
1092
|
+
{"Model": model_name, "Serial": serial_number or "N/A"},
|
|
1093
|
+
)
|
|
1094
|
+
error_info = failed[0] if failed else {}
|
|
1095
|
+
error_msg = error_info.get("error", {}).get("message", "Unknown error")
|
|
1096
|
+
click.echo(f"⚠ Warning: Some assets failed to create: {error_msg}", err=True)
|
|
1097
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1098
|
+
elif assets:
|
|
1099
|
+
# Complete success
|
|
1100
|
+
format_success(
|
|
1101
|
+
"Asset created",
|
|
1102
|
+
{"Model": model_name, "Serial": serial_number or "N/A"},
|
|
1103
|
+
)
|
|
1104
|
+
elif failed:
|
|
1105
|
+
# Complete failure
|
|
1106
|
+
error_info = failed[0] if failed else {}
|
|
1107
|
+
error_msg = error_info.get("error", {}).get("message", "Unknown error")
|
|
1108
|
+
click.echo(f"✗ Asset creation failed: {error_msg}", err=True)
|
|
1109
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1110
|
+
else:
|
|
1111
|
+
# Edge case: empty response
|
|
1112
|
+
format_success(
|
|
1113
|
+
"Asset created",
|
|
1114
|
+
{"Model": model_name, "Serial": serial_number or "N/A"},
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
except Exception as exc: # noqa: BLE001
|
|
1118
|
+
handle_api_error(exc)
|
|
1119
|
+
|
|
1120
|
+
@asset.command(name="update")
|
|
1121
|
+
@click.argument("asset_id")
|
|
1122
|
+
@click.option("--name", "asset_name", default=None, help="Update display name")
|
|
1123
|
+
@click.option("--model-name", default=None, help="Update model name")
|
|
1124
|
+
@click.option("--model-number", default=None, help="Update model number")
|
|
1125
|
+
@click.option("--serial-number", default=None, help="Update serial number")
|
|
1126
|
+
@click.option("--vendor-name", default=None, help="Update vendor name")
|
|
1127
|
+
@click.option("--part-number", default=None, help="Update part number")
|
|
1128
|
+
@click.option("--firmware-version", default=None, help="Update firmware version")
|
|
1129
|
+
@click.option("--hardware-version", default=None, help="Update hardware version")
|
|
1130
|
+
@click.option(
|
|
1131
|
+
"--keyword",
|
|
1132
|
+
"keywords",
|
|
1133
|
+
multiple=True,
|
|
1134
|
+
help="Replace keywords (repeatable)",
|
|
1135
|
+
)
|
|
1136
|
+
@click.option(
|
|
1137
|
+
"--property",
|
|
1138
|
+
"properties",
|
|
1139
|
+
multiple=True,
|
|
1140
|
+
help="Replace properties in key=value format (repeatable)",
|
|
1141
|
+
)
|
|
1142
|
+
@click.option(
|
|
1143
|
+
"--format",
|
|
1144
|
+
"-f",
|
|
1145
|
+
type=click.Choice(["table", "json"]),
|
|
1146
|
+
default="table",
|
|
1147
|
+
show_default=True,
|
|
1148
|
+
help="Output format",
|
|
1149
|
+
)
|
|
1150
|
+
def update_asset(
|
|
1151
|
+
asset_id: str,
|
|
1152
|
+
asset_name: Optional[str],
|
|
1153
|
+
model_name: Optional[str],
|
|
1154
|
+
model_number: Optional[str],
|
|
1155
|
+
serial_number: Optional[str],
|
|
1156
|
+
vendor_name: Optional[str],
|
|
1157
|
+
part_number: Optional[str],
|
|
1158
|
+
firmware_version: Optional[str],
|
|
1159
|
+
hardware_version: Optional[str],
|
|
1160
|
+
keywords: Tuple[str, ...],
|
|
1161
|
+
properties: Tuple[str, ...],
|
|
1162
|
+
format: str,
|
|
1163
|
+
) -> None:
|
|
1164
|
+
"""Update an existing asset's properties.
|
|
1165
|
+
|
|
1166
|
+
ASSET_ID is the unique identifier of the asset to update.
|
|
1167
|
+
Only the specified fields are changed; others remain unchanged.
|
|
1168
|
+
"""
|
|
1169
|
+
check_readonly_mode("update an asset")
|
|
1170
|
+
format_output = validate_output_format(format)
|
|
1171
|
+
|
|
1172
|
+
try:
|
|
1173
|
+
# Build update payload — include ID and only changed fields
|
|
1174
|
+
update_data: Dict[str, Any] = {"id": asset_id}
|
|
1175
|
+
|
|
1176
|
+
if asset_name is not None:
|
|
1177
|
+
update_data["name"] = asset_name
|
|
1178
|
+
if model_name is not None:
|
|
1179
|
+
update_data["modelName"] = model_name
|
|
1180
|
+
if model_number is not None:
|
|
1181
|
+
update_data["modelNumber"] = model_number
|
|
1182
|
+
if serial_number is not None:
|
|
1183
|
+
update_data["serialNumber"] = serial_number
|
|
1184
|
+
if vendor_name is not None:
|
|
1185
|
+
update_data["vendorName"] = vendor_name
|
|
1186
|
+
if part_number is not None:
|
|
1187
|
+
update_data["partNumber"] = part_number
|
|
1188
|
+
if firmware_version is not None:
|
|
1189
|
+
update_data["firmwareVersion"] = firmware_version
|
|
1190
|
+
if hardware_version is not None:
|
|
1191
|
+
update_data["hardwareVersion"] = hardware_version
|
|
1192
|
+
|
|
1193
|
+
if keywords:
|
|
1194
|
+
update_data["keywords"] = list(keywords)
|
|
1195
|
+
|
|
1196
|
+
if properties:
|
|
1197
|
+
update_data["properties"] = _parse_properties(properties)
|
|
1198
|
+
|
|
1199
|
+
url = f"{_get_asset_base_url()}/update-assets"
|
|
1200
|
+
payload: Dict[str, Any] = {"assets": [update_data]}
|
|
1201
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
1202
|
+
|
|
1203
|
+
if format_output.lower() == "json":
|
|
1204
|
+
click.echo(json.dumps(resp.json(), indent=2))
|
|
1205
|
+
else:
|
|
1206
|
+
format_success("Asset updated", {"ID": asset_id})
|
|
1207
|
+
|
|
1208
|
+
except Exception as exc: # noqa: BLE001
|
|
1209
|
+
handle_api_error(exc)
|
|
1210
|
+
|
|
1211
|
+
@asset.command(name="delete")
|
|
1212
|
+
@click.argument("asset_id")
|
|
1213
|
+
@click.option(
|
|
1214
|
+
"--force",
|
|
1215
|
+
is_flag=True,
|
|
1216
|
+
help="Delete without confirmation",
|
|
1217
|
+
)
|
|
1218
|
+
def delete_asset(
|
|
1219
|
+
asset_id: str,
|
|
1220
|
+
force: bool,
|
|
1221
|
+
) -> None:
|
|
1222
|
+
"""Delete an asset.
|
|
1223
|
+
|
|
1224
|
+
ASSET_ID is the unique identifier of the asset to delete.
|
|
1225
|
+
"""
|
|
1226
|
+
check_readonly_mode("delete an asset")
|
|
1227
|
+
|
|
1228
|
+
try:
|
|
1229
|
+
# Fetch asset info for confirmation display
|
|
1230
|
+
try:
|
|
1231
|
+
info_url = f"{_get_asset_base_url()}/assets/{asset_id}"
|
|
1232
|
+
info_resp = make_api_request("GET", info_url)
|
|
1233
|
+
info = info_resp.json()
|
|
1234
|
+
display_name = info.get("name") or info.get("modelName") or asset_id
|
|
1235
|
+
except Exception:
|
|
1236
|
+
display_name = asset_id
|
|
1237
|
+
|
|
1238
|
+
if not force:
|
|
1239
|
+
if not questionary.confirm(
|
|
1240
|
+
f"Are you sure you want to delete asset '{display_name}'?",
|
|
1241
|
+
default=False,
|
|
1242
|
+
).ask():
|
|
1243
|
+
click.echo("Delete cancelled.")
|
|
1244
|
+
sys.exit(ExitCodes.SUCCESS)
|
|
1245
|
+
|
|
1246
|
+
url = f"{_get_asset_base_url()}/delete-assets"
|
|
1247
|
+
payload: Dict[str, Any] = {"ids": [asset_id]}
|
|
1248
|
+
make_api_request("POST", url, payload=payload)
|
|
1249
|
+
|
|
1250
|
+
format_success("Asset deleted", {"Name": display_name, "ID": asset_id})
|
|
1251
|
+
|
|
1252
|
+
except Exception as exc: # noqa: BLE001
|
|
1253
|
+
handle_api_error(exc)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _summarize_assets(
|
|
1257
|
+
assets: List[Dict[str, Any]],
|
|
1258
|
+
max_items: Optional[int] = 10000,
|
|
1259
|
+
) -> Dict[str, Any]:
|
|
1260
|
+
"""Summarize asset data by aggregating bus types and calibration status.
|
|
1261
|
+
|
|
1262
|
+
Args:
|
|
1263
|
+
assets: List of asset objects.
|
|
1264
|
+
max_items: Maximum items that were fetched.
|
|
1265
|
+
|
|
1266
|
+
Returns:
|
|
1267
|
+
Dictionary with summary statistics.
|
|
1268
|
+
"""
|
|
1269
|
+
summary: Dict[str, Any] = {"total": len(assets)}
|
|
1270
|
+
|
|
1271
|
+
if max_items is not None and len(assets) >= max_items:
|
|
1272
|
+
summary["truncated"] = True
|
|
1273
|
+
summary["note"] = f"Results limited to {max_items} items"
|
|
1274
|
+
|
|
1275
|
+
# Group by bus type
|
|
1276
|
+
bus_types: Dict[str, int] = {}
|
|
1277
|
+
for a in assets:
|
|
1278
|
+
bt = str(a.get("busType", "N/A"))
|
|
1279
|
+
bus_types[bt] = bus_types.get(bt, 0) + 1
|
|
1280
|
+
summary["busTypes"] = bus_types
|
|
1281
|
+
|
|
1282
|
+
# Group by calibration status
|
|
1283
|
+
cal_statuses: Dict[str, int] = {}
|
|
1284
|
+
for a in assets:
|
|
1285
|
+
cs = str(a.get("calibrationStatus", "N/A"))
|
|
1286
|
+
cal_statuses[cs] = cal_statuses.get(cs, 0) + 1
|
|
1287
|
+
summary["calibrationStatuses"] = cal_statuses
|
|
1288
|
+
|
|
1289
|
+
return summary
|