qapu-cli 0.78.0__tar.gz → 0.80.0__tar.gz
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.
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/PKG-INFO +1 -1
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/pyproject.toml +1 -1
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/client.py +15 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/command_packages.py +7 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/main.py +153 -66
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/PKG-INFO +1 -1
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/README.md +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/__init__.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/__init__.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/app.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/app.tcss +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/data.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/rendering.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/__init__.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/catalog.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/catalog_form.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/catalog_picker.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/confirm.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/dashboard.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/device_authorization.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/device_edit.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/device_list.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/equipment_form.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/group_detail.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/group_form.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/group_list.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/infrastructure.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/widgets/__init__.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/widgets/device_table.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/visibility.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/SOURCES.txt +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/dependency_links.txt +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/entry_points.txt +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/requires.txt +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/top_level.txt +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/setup.cfg +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_command_packages_complete.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_login.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_logout.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_authorization.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_catalog.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_device_edit.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_electrical_parent.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_groups.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_infrastructure.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_smoke.py +0 -0
- {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_visibility.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qapu-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.80.0
|
|
4
4
|
Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
|
|
5
5
|
Author: OVOO Technology
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "qapu-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.80.0"
|
|
4
4
|
description = "CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -261,6 +261,21 @@ def _request(method: str, path: str, params: dict[str, Any] | None = None, json_
|
|
|
261
261
|
raise ClientError(_report_request_error(e, method, url))
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
# Upload one File as multipart/form-data (2026-10-26, Firmware Files) - a
|
|
265
|
+
# Longer Write/Read Timeout Than JSON Calls: the Server Also Relays it to FTP
|
|
266
|
+
def post_file(path: str, file_path: str, field: str = "file", data: dict[str, Any] | None = None) -> Any:
|
|
267
|
+
url = f"{_base_url()}{path}"
|
|
268
|
+
try:
|
|
269
|
+
with open(file_path, "rb") as handle:
|
|
270
|
+
response = httpx.post(url, headers=_headers(), files={field: (os.path.basename(file_path), handle, "application/octet-stream")}, data=data or {}, timeout=httpx.Timeout(connect=5.0, read=120.0, write=120.0, pool=5.0))
|
|
271
|
+
response.raise_for_status()
|
|
272
|
+
return response.json()
|
|
273
|
+
except httpx.HTTPStatusError as e:
|
|
274
|
+
raise ClientError(_report_http_status_error(e, "POST", url))
|
|
275
|
+
except httpx.RequestError as e:
|
|
276
|
+
raise ClientError(_report_request_error(e, "POST", url))
|
|
277
|
+
|
|
278
|
+
|
|
264
279
|
def post(path: str, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None) -> Any:
|
|
265
280
|
|
|
266
281
|
# Issue a POST Request
|
|
@@ -125,7 +125,11 @@ COMMAND_REQUIRED_PACKAGES: dict[str, frozenset[str]] = {
|
|
|
125
125
|
"fleet_data": frozenset({"telemetry:read"}),
|
|
126
126
|
"fleet_score": frozenset({"scores:read"}),
|
|
127
127
|
"fleet_status": frozenset({"devices:read", "infrastructure:read", "platform-config:read"}),
|
|
128
|
+
"fota_approvals": frozenset({"hardware-catalog:read"}),
|
|
129
|
+
"fota_approve": frozenset({"hardware-catalog:write"}),
|
|
130
|
+
"fota_confirmations": frozenset({"hardware-catalog:read"}),
|
|
128
131
|
"fota_ftp": frozenset({"device-actuation:write", "hardware-catalog:read"}),
|
|
132
|
+
"fota_revoke": frozenset({"hardware-catalog:write"}),
|
|
129
133
|
"fota_http": frozenset({"device-actuation:write", "hardware-catalog:read"}),
|
|
130
134
|
"fota_upload": frozenset({"hardware-catalog:read", "hardware-catalog:write"}),
|
|
131
135
|
"group_add": frozenset({"infrastructure:write"}),
|
|
@@ -165,8 +169,11 @@ COMMAND_REQUIRED_PACKAGES: dict[str, frozenset[str]] = {
|
|
|
165
169
|
"model_get": frozenset({"hardware-catalog:read"}),
|
|
166
170
|
"model_list": frozenset({"hardware-catalog:read"}),
|
|
167
171
|
"model_update": frozenset({"hardware-catalog:write"}),
|
|
172
|
+
"modem_add": frozenset({"hardware-catalog:write"}),
|
|
173
|
+
"modem_delete": frozenset({"hardware-catalog:write"}),
|
|
168
174
|
"modem_get": frozenset({"hardware-catalog:read"}),
|
|
169
175
|
"modem_list": frozenset({"hardware-catalog:read"}),
|
|
176
|
+
"modem_update": frozenset({"hardware-catalog:write"}),
|
|
170
177
|
"mqtt_provision": frozenset({"device-actuation:write"}),
|
|
171
178
|
"pipeline_stats": frozenset({"platform-config:read"}),
|
|
172
179
|
"project_add": frozenset({"platform-config:write"}),
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
# from outside the Swarm. See client.py for the HTTP layer, README.md for
|
|
4
4
|
# setup and the auth placeholder this currently relies on.
|
|
5
5
|
|
|
6
|
-
import ftplib
|
|
7
6
|
import hashlib
|
|
8
7
|
import json
|
|
9
8
|
import os
|
|
@@ -1428,7 +1427,7 @@ def _build_score_compact_table(rows: list[dict[str, Any]], show_device: bool = T
|
|
|
1428
1427
|
table.add_column("YÜK", justify="right")
|
|
1429
1428
|
table.add_column("ELEK.", justify="right")
|
|
1430
1429
|
table.add_column("VERİ GÜV.", justify="right")
|
|
1431
|
-
table.add_column("MOTOR
|
|
1430
|
+
table.add_column("MOTOR", justify="right")
|
|
1432
1431
|
table.add_column("DURUM")
|
|
1433
1432
|
table.add_column("AI")
|
|
1434
1433
|
table.add_column("KAYIT")
|
|
@@ -1447,7 +1446,7 @@ def _build_score_compact_table(rows: list[dict[str, Any]], show_device: bool = T
|
|
|
1447
1446
|
_colorize_score(row.get("Load_Behavior_Score")),
|
|
1448
1447
|
_colorize_score(row.get("Electrical_Status_Score")),
|
|
1449
1448
|
_colorize_score(data_reliability),
|
|
1450
|
-
|
|
1449
|
+
_colorize_score(row.get("Motor_Stress_Score")),
|
|
1451
1450
|
_colorize_status(row.get("Calculation_Status")),
|
|
1452
1451
|
row.get("Ai_Status") or "-",
|
|
1453
1452
|
row.get("Record_Status") or "-",
|
|
@@ -1490,8 +1489,8 @@ def _build_score_trend_table(rows: list[dict[str, Any]]) -> Optional[Table]:
|
|
|
1490
1489
|
chronological = list(reversed(rows))
|
|
1491
1490
|
columns = [
|
|
1492
1491
|
("GRID", "Grid_Score"), ("REAKTİF", "Reactive_Score"), ("YÜK", "Load_Behavior_Score"),
|
|
1493
|
-
("ELEK.", "Electrical_Status_Score"), ("VERİ GÜV.", "
|
|
1494
|
-
("MOTOR
|
|
1492
|
+
("ELEK.", "Electrical_Status_Score"), ("VERİ GÜV.", "Data_Reliability_Score"),
|
|
1493
|
+
("MOTOR", "Motor_Stress_Score"),
|
|
1495
1494
|
]
|
|
1496
1495
|
table = Table(show_header=True, header_style="bold", title=f"Trend ({len(chronological)} kayıt, eski→yeni)", title_justify="left", box=box.SIMPLE)
|
|
1497
1496
|
table.add_column("KART")
|
|
@@ -1708,6 +1707,8 @@ def _build_score_detail_view(row: dict[str, Any], previous: Optional[dict[str, A
|
|
|
1708
1707
|
("Seviye", _format_score_value(row.get("Grid_Voltage_Level_Score"))),
|
|
1709
1708
|
("Denge", _format_score_value(row.get("Grid_Voltage_Balance_Score"))),
|
|
1710
1709
|
("Harmonik", _format_score_value(row.get("Grid_Harmonic_Score"))),
|
|
1710
|
+
("Frekans", _format_score_value(grid_det.get("S_freq"))),
|
|
1711
|
+
("Kesinti", f"{grid_det.get('interruption_count')} kez / {round((grid_det.get('interruption_seconds') or 0) / 60)} dk" if grid_det.get("interruption_count") else "-"),
|
|
1711
1712
|
("Belirleyici", grid_det.get("determinant_dimension")),
|
|
1712
1713
|
], status_row=("Durum", row.get("Grid_Status")), method_code="CINGA-GRID-SCORE", version=_mv("CINGA-GRID-SCORE")),
|
|
1713
1714
|
_score_detail_panel("Reaktif — Ceza Riski", _score_color(row.get("Reactive_Score")), "Skor", row.get("Reactive_Score"), previous.get("Reactive_Score"), [
|
|
@@ -1719,14 +1720,15 @@ def _build_score_detail_view(row: dict[str, Any], previous: Optional[dict[str, A
|
|
|
1719
1720
|
("Seviye", _format_score_value(row.get("Reactive_Compensation_Level_Score"))),
|
|
1720
1721
|
("Faz Dengesi", _format_score_value(row.get("Reactive_Compensation_Phase_Balance_Score"))),
|
|
1721
1722
|
("Kararlılık", _format_score_value(row.get("Reactive_Compensation_Stability_Score"))),
|
|
1723
|
+
("Boşta Reaktif", _format_score_value((row.get("Reactive_Compensation_Determinants") or {}).get("S_idle"))),
|
|
1722
1724
|
("Yön", row.get("Reactive_Compensation_Direction")),
|
|
1723
1725
|
], status_row=("Durum", row.get("Reactive_Compensation_Status")), method_code="CINGA-REACTIVE-SCORE", version=_mv("CINGA-REACTIVE-SCORE")),
|
|
1724
1726
|
_score_detail_panel("Yük Davranışı", _score_color(row.get("Load_Behavior_Score")), "Skor", row.get("Load_Behavior_Score"), previous.get("Load_Behavior_Score"), [
|
|
1725
1727
|
("Yüklenme", _format_score_value(row.get("Load_Operating_Point_Score"))),
|
|
1726
1728
|
("Faz Dengesi", _format_score_value(row.get("Load_Current_Balance_Score"))),
|
|
1727
1729
|
("Dalga Biçimi", _format_score_value(row.get("Load_Current_Waveform_Score"))),
|
|
1728
|
-
("Güç
|
|
1729
|
-
("
|
|
1730
|
+
("Güç Kararlılığı", _format_score_value(row.get("Load_Regime_Stability_Score"))),
|
|
1731
|
+
("Bağlantı", (row.get("Load_Determinants") or {}).get("connection_type") or "-"),
|
|
1730
1732
|
], status_row=("Durum", row.get("Load_Status")), method_code="CINGA-LOAD-BEHAVIOR-SCORE", version=_mv("CINGA-LOAD-BEHAVIOR-SCORE")),
|
|
1731
1733
|
_score_detail_panel("Elektriksel Durum", _score_color(row.get("Electrical_Status_Score")), "Skor", row.get("Electrical_Status_Score"), previous.get("Electrical_Status_Score"), [
|
|
1732
1734
|
("Dahil", ", ".join(row.get("Included_Dimensions") or []) or "-"),
|
|
@@ -1735,11 +1737,14 @@ def _build_score_detail_view(row: dict[str, Any], previous: Optional[dict[str, A
|
|
|
1735
1737
|
Panel(Group(*[
|
|
1736
1738
|
f"{label}: [{_score_color(row.get(field))}]{_format_score_value(row.get(field))}[/{_score_color(row.get(field))}]{_delta_suffix(row.get(field), previous.get(field))}"
|
|
1737
1739
|
for label, field in (
|
|
1738
|
-
("
|
|
1739
|
-
("
|
|
1740
|
+
("Toplam", "Data_Reliability_Score"),
|
|
1741
|
+
("Tamlık", "Data_Reliability_Completeness_Score"), ("Tutarlılık", "Data_Reliability_Consistency_Score"),
|
|
1742
|
+
("Güncellik (gecikme)", "Data_Reliability_Timeliness_Score"), ("İzlenebilirlik", "Data_Reliability_Traceability_Score"),
|
|
1740
1743
|
)
|
|
1741
1744
|
]), title="Veri Güvenilirliği", subtitle=(f"[dim]DATA-RELIABILITY-SCORE v{_mv('CINGA-DATA-RELIABILITY-SCORE')}[/dim]" if _mv("CINGA-DATA-RELIABILITY-SCORE") else None), subtitle_align="right", border_style="blue", width=36, height=SCORE_PANEL_HEIGHT),
|
|
1742
|
-
_score_detail_panel("Motor Zorlanması",
|
|
1745
|
+
_score_detail_panel("Motor Zorlanması", _score_color(row.get("Motor_Stress_Score")), "Skor", row.get("Motor_Stress_Score"), previous.get("Motor_Stress_Score"), [
|
|
1746
|
+
("NEMA LVUR %", _format_score_value(row.get("Motor_Stress_U_Nema_Pct"))),
|
|
1747
|
+
("Düşürme Katsayısı", row.get("Motor_Stress_K_D_Reference")),
|
|
1743
1748
|
("Aşım Süresi (sn)", row.get("Motor_Stress_Exceedance_Duration_Seconds")),
|
|
1744
1749
|
("Aşım Sayısı", row.get("Motor_Stress_Exceedance_Count")),
|
|
1745
1750
|
("Tahmini Ek Isınma (%)", _format_score_value(row.get("Motor_Stress_Estimated_Extra_Heating_Pct"))),
|
|
@@ -4271,12 +4276,62 @@ def modem_get(imei: str = typer.Argument(...), as_json: bool = typer.Option(Fals
|
|
|
4271
4276
|
_print_detail(result)
|
|
4272
4277
|
|
|
4273
4278
|
|
|
4279
|
+
# Modem Writes (2026-10-26) - PUT/POST/DELETE /hermes/modems
|
|
4280
|
+
def _modem_body(model_id: Optional[int], status_id: Optional[int]) -> dict[str, Any]:
|
|
4281
|
+
body: dict[str, Any] = {}
|
|
4282
|
+
if model_id is not None:
|
|
4283
|
+
body["Model"] = {"ID": model_id}
|
|
4284
|
+
if status_id is not None:
|
|
4285
|
+
body["Status"] = {"ID": status_id}
|
|
4286
|
+
return body
|
|
4287
|
+
|
|
4288
|
+
|
|
4289
|
+
@modem_app.command("add")
|
|
4290
|
+
def modem_add(
|
|
4291
|
+
imei: str = typer.Argument(..., help="15-digit IMEI."),
|
|
4292
|
+
model_id: Optional[int] = typer.Option(None, "--model-id", help="A modem model (manufacturer in the 2000 block, e.g. 2007 LE910C1-EUX) or 0 Unknown."),
|
|
4293
|
+
status_id: Optional[int] = typer.Option(None, "--status-id", help="Modem status 301-399 (e.g. 304 In Stock) or 0."),
|
|
4294
|
+
):
|
|
4295
|
+
"""Add a modem (POST /hermes/modems). Devices also register their modem automatically on the first packet."""
|
|
4296
|
+
result = client.post("/hermes/modems", json_body={"IMEI": imei, **_modem_body(model_id, status_id)})
|
|
4297
|
+
typer.echo(result.get("Message", result))
|
|
4298
|
+
|
|
4299
|
+
|
|
4300
|
+
@modem_app.command("update")
|
|
4301
|
+
def modem_update(
|
|
4302
|
+
imei: str = typer.Argument(...),
|
|
4303
|
+
model_id: Optional[int] = typer.Option(None, "--model-id"),
|
|
4304
|
+
status_id: Optional[int] = typer.Option(None, "--status-id"),
|
|
4305
|
+
):
|
|
4306
|
+
"""Change a modem's model and/or status (PUT /hermes/modems/{imei}). The IMEI can't change."""
|
|
4307
|
+
body = _modem_body(model_id, status_id)
|
|
4308
|
+
if not body:
|
|
4309
|
+
typer.echo("Nothing to update - give --model-id and/or --status-id.")
|
|
4310
|
+
raise typer.Exit(1)
|
|
4311
|
+
result = client.put(f"/hermes/modems/{imei}", json_body=body)
|
|
4312
|
+
typer.echo(result.get("Message", result))
|
|
4313
|
+
|
|
4314
|
+
|
|
4315
|
+
@modem_app.command("delete")
|
|
4316
|
+
def modem_delete(imei: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt.")):
|
|
4317
|
+
"""Delete an unused modem (DELETE /hermes/modems/{imei}). A modem a device uses can't be deleted (it would take the device record with it) - change its status instead."""
|
|
4318
|
+
if not yes and not typer.confirm(f"Delete modem {imei}?"):
|
|
4319
|
+
raise typer.Exit(0)
|
|
4320
|
+
result = client.delete(f"/hermes/modems/{imei}")
|
|
4321
|
+
typer.echo(result.get("Message", result))
|
|
4322
|
+
|
|
4323
|
+
|
|
4324
|
+
# SIM Lifecycle States (statuses 401-406)
|
|
4325
|
+
_SIM_STATE_HELP = "SIM state: 401 Stokta, 402 Aktif, 403 Askıda, 404 Arızalı, 405 İptal, 406 Test SIM'i."
|
|
4326
|
+
|
|
4327
|
+
|
|
4274
4328
|
# List SIMs
|
|
4275
4329
|
@sim_app.command("list")
|
|
4276
4330
|
def sim_list(
|
|
4277
4331
|
as_json: bool = typer.Option(False, "--json"),
|
|
4278
4332
|
operator_id: Optional[int] = typer.Option(None, "--operator-id"),
|
|
4279
|
-
active: Optional[bool] = typer.Option(None, "--active/--inactive"),
|
|
4333
|
+
active: Optional[bool] = typer.Option(None, "--active/--inactive", help="In service (Aktif or Test SIM'i) or not."),
|
|
4334
|
+
state: Optional[int] = typer.Option(None, "--state", help=_SIM_STATE_HELP),
|
|
4280
4335
|
limit: Optional[int] = typer.Option(None, "--limit"),
|
|
4281
4336
|
):
|
|
4282
4337
|
"""List the SIM inventory (GET /hermes/sims)."""
|
|
@@ -4285,13 +4340,15 @@ def sim_list(
|
|
|
4285
4340
|
params["operator_id"] = operator_id
|
|
4286
4341
|
if active is not None:
|
|
4287
4342
|
params["active"] = active
|
|
4343
|
+
if state is not None:
|
|
4344
|
+
params["state_id"] = state
|
|
4288
4345
|
items: list[dict[str, Any]] = client.get("/hermes/sims", params=params)
|
|
4289
4346
|
if limit is not None:
|
|
4290
4347
|
items = items[:limit]
|
|
4291
4348
|
if as_json:
|
|
4292
4349
|
typer.echo(json.dumps(items, indent=2))
|
|
4293
4350
|
return
|
|
4294
|
-
_print_paginated(items, _simple_table_builder([("ICCID", "ICCID"), ("NUMBER", "Number"), ("OPERATOR", "Operator.MNC_Brand"), ("APN", "APN"), ("INSTITUTIONAL", "Institutional"), ("STATIC_IP", "Static_IP"), ("
|
|
4351
|
+
_print_paginated(items, _simple_table_builder([("ICCID", "ICCID"), ("NUMBER", "Number"), ("OPERATOR", "Operator.MNC_Brand"), ("APN", "APN"), ("INSTITUTIONAL", "Institutional"), ("STATIC_IP", "Static_IP"), ("STATE", "State.Name")]), limit=limit, noun="SIM")
|
|
4295
4352
|
|
|
4296
4353
|
|
|
4297
4354
|
# Get One SIM's Detail
|
|
@@ -4306,10 +4363,12 @@ def sim_get(iccid: str = typer.Argument(...), as_json: bool = typer.Option(False
|
|
|
4306
4363
|
|
|
4307
4364
|
|
|
4308
4365
|
# SIM Body From CLI Options - Only What was Given
|
|
4309
|
-
def _sim_body(operator_id: Optional[int], number: Optional[str], static_ip: Optional[str], apn: Optional[str], institutional: Optional[bool], active: Optional[bool]) -> dict[str, Any]:
|
|
4366
|
+
def _sim_body(operator_id: Optional[int], number: Optional[str], static_ip: Optional[str], apn: Optional[str], institutional: Optional[bool], active: Optional[bool], state: Optional[int] = None) -> dict[str, Any]:
|
|
4310
4367
|
body: dict[str, Any] = {}
|
|
4311
4368
|
if operator_id is not None:
|
|
4312
4369
|
body["Operator"] = {"ID": operator_id}
|
|
4370
|
+
if state is not None:
|
|
4371
|
+
body["State"] = {"ID": state}
|
|
4313
4372
|
for key, value in (("Number", number), ("Static_IP", static_ip), ("APN", apn), ("Institutional", institutional), ("Status", active)):
|
|
4314
4373
|
if value is not None:
|
|
4315
4374
|
body[key] = value
|
|
@@ -4325,10 +4384,11 @@ def sim_add(
|
|
|
4325
4384
|
static_ip: Optional[str] = typer.Option(None, "--static-ip"),
|
|
4326
4385
|
apn: Optional[str] = typer.Option(None, "--apn"),
|
|
4327
4386
|
institutional: Optional[bool] = typer.Option(None, "--institutional/--not-institutional", help="SIM supplied by a customer institution."),
|
|
4328
|
-
active: Optional[bool] = typer.Option(None, "--active/--inactive"),
|
|
4387
|
+
active: Optional[bool] = typer.Option(None, "--active/--inactive", help="Shortcut for --state 402/403."),
|
|
4388
|
+
state: Optional[int] = typer.Option(None, "--state", help=_SIM_STATE_HELP + " Default 402."),
|
|
4329
4389
|
):
|
|
4330
4390
|
"""Add a SIM (POST /hermes/sims)."""
|
|
4331
|
-
result = client.post("/hermes/sims", json_body={"ICCID": iccid, **_sim_body(operator_id, number, static_ip, apn, institutional, active)})
|
|
4391
|
+
result = client.post("/hermes/sims", json_body={"ICCID": iccid, **_sim_body(operator_id, number, static_ip, apn, institutional, active, state)})
|
|
4332
4392
|
typer.echo(result.get("Message", result))
|
|
4333
4393
|
|
|
4334
4394
|
|
|
@@ -4341,10 +4401,11 @@ def sim_update(
|
|
|
4341
4401
|
static_ip: Optional[str] = typer.Option(None, "--static-ip"),
|
|
4342
4402
|
apn: Optional[str] = typer.Option(None, "--apn"),
|
|
4343
4403
|
institutional: Optional[bool] = typer.Option(None, "--institutional/--not-institutional"),
|
|
4344
|
-
active: Optional[bool] = typer.Option(None, "--active/--inactive"),
|
|
4404
|
+
active: Optional[bool] = typer.Option(None, "--active/--inactive", help="Shortcut for --state 402/403 (only if that changes whether it is in service)."),
|
|
4405
|
+
state: Optional[int] = typer.Option(None, "--state", help=_SIM_STATE_HELP),
|
|
4345
4406
|
):
|
|
4346
4407
|
"""Update a SIM - only the options given change (PUT /hermes/sims/{iccid}). APN/operator are also refreshed from the device's own reports."""
|
|
4347
|
-
body = _sim_body(operator_id, number, static_ip, apn, institutional, active)
|
|
4408
|
+
body = _sim_body(operator_id, number, static_ip, apn, institutional, active, state)
|
|
4348
4409
|
if not body:
|
|
4349
4410
|
typer.echo("Nothing to update - give at least one option.")
|
|
4350
4411
|
raise typer.Exit(1)
|
|
@@ -4355,7 +4416,7 @@ def sim_update(
|
|
|
4355
4416
|
# Delete a SIM - Refused by the API if a Device or any Stored Packet Uses it
|
|
4356
4417
|
@sim_app.command("delete")
|
|
4357
4418
|
def sim_delete(iccid: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt.")):
|
|
4358
|
-
"""Delete an unused SIM (DELETE /hermes/sims/{iccid}). A SIM used by a device or any stored packet can't be deleted -
|
|
4419
|
+
"""Delete an unused SIM (DELETE /hermes/sims/{iccid}). A SIM used by a device or any stored packet can't be deleted - retire it with `qapu sim update ICCID --state 405`."""
|
|
4359
4420
|
if not yes and not typer.confirm(f"Delete SIM {iccid}?"):
|
|
4360
4421
|
raise typer.Exit(0)
|
|
4361
4422
|
result = client.delete(f"/hermes/sims/{iccid}")
|
|
@@ -7473,68 +7534,47 @@ def mqtt_provision(device_id: str = typer.Argument(..., help="Device ID to provi
|
|
|
7473
7534
|
# Metadata to Hermes.
|
|
7474
7535
|
@fota_app.command("upload")
|
|
7475
7536
|
def fota_upload(
|
|
7476
|
-
file_path: str = typer.Argument(..., help="Local
|
|
7537
|
+
file_path: str = typer.Argument(..., help="Local firmware binary. Name rules: 1-23 chars [A-Za-z0-9._-], ending .bin, first two characters = the version's major (e.g. 01.00.24.bin), at most 4 MB."),
|
|
7477
7538
|
version: str = typer.Option(..., "--version", help="MAJOR.MINOR.PATCH, e.g. 01.00.24."),
|
|
7478
7539
|
title: Optional[str] = typer.Option(None, "--title"),
|
|
7479
7540
|
description: Optional[str] = typer.Option(None, "--description"),
|
|
7480
7541
|
channel: Optional[str] = typer.Option(None, "--channel"),
|
|
7481
|
-
active: Optional[bool] = typer.Option(None, "--active/--inactive"),
|
|
7542
|
+
active: Optional[bool] = typer.Option(None, "--active/--inactive", help="Open (or close) the version for FOTA after the upload. Not the same as `qapu fota approve` (trial-boot confirmation)."),
|
|
7482
7543
|
release_notes: Optional[str] = typer.Option(None, "--release-notes"),
|
|
7483
|
-
ftp_host: str = typer.Option(_FIRMWARE_SERVER_DEFAULT, "--ftp-host", help="The firmware FTP server's address - already resolves to the real public IP (confirmed live), same hostname `fota http`/`fota ftp` default to."),
|
|
7484
|
-
http_base_url: str = typer.Option(f"https://{_FIRMWARE_SERVER_DEFAULT}", "--http-base-url", help="Base URL the file is reachable at over HTTP - used to build the catalog's File_URL."),
|
|
7485
7544
|
):
|
|
7486
|
-
"""Publish a
|
|
7545
|
+
"""Publish a firmware file end to end through the API (2026-10-26): register the version in the catalog if it's new, upload the file (POST /hermes/firmware/{version}/file - the server stores it on the firmware FTP server and records name/size/MD5), then apply --active if given. No FTP password needed locally any more."""
|
|
7487
7546
|
|
|
7488
7547
|
# Validate Local File
|
|
7489
7548
|
if not os.path.isfile(file_path):
|
|
7490
7549
|
typer.echo(f"Error: '{file_path}' is not a file.", err=True)
|
|
7491
7550
|
raise typer.Exit(code=1)
|
|
7492
7551
|
|
|
7493
|
-
#
|
|
7494
|
-
ftp_password = os.environ.get("QAPU_FTP_ADMIN_PASSWORD")
|
|
7495
|
-
if not ftp_password:
|
|
7496
|
-
typer.echo("Error: QAPU_FTP_ADMIN_PASSWORD environment variable is not set.", err=True)
|
|
7497
|
-
raise typer.Exit(code=1)
|
|
7498
|
-
|
|
7499
|
-
file_name = os.path.basename(file_path)
|
|
7500
|
-
file_size = os.path.getsize(file_path)
|
|
7501
|
-
|
|
7502
|
-
# Compute MD5 - Streamed, not Loaded Whole into Memory
|
|
7552
|
+
# Local MD5 - the Server Recomputes it and Refuses (422) if They Differ
|
|
7503
7553
|
md5_hash = hashlib.md5()
|
|
7504
7554
|
with open(file_path, "rb") as handle:
|
|
7505
|
-
for chunk in iter(lambda: handle.read(
|
|
7555
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
7506
7556
|
md5_hash.update(chunk)
|
|
7507
7557
|
md5_hex = md5_hash.hexdigest()
|
|
7508
7558
|
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
ftp.storbinary(f"STOR {file_name}", handle)
|
|
7518
|
-
except ftplib.all_errors as e:
|
|
7519
|
-
typer.echo(f"Error: FTP upload failed: {e}", err=True)
|
|
7520
|
-
raise typer.Exit(code=1)
|
|
7521
|
-
|
|
7522
|
-
file_url = f"{http_base_url.rstrip('/')}/{file_name}"
|
|
7559
|
+
# Register the Version First if it's New (Without File Fields or Active -
|
|
7560
|
+
# Active Needs a File, so it's Applied Only After the Upload)
|
|
7561
|
+
metadata = _firmware_field_body(title, description, channel, None, None, None, None, None, None, None, release_notes)
|
|
7562
|
+
if client.get_or_none(f"/hermes/firmware/{version}") is None:
|
|
7563
|
+
client.post("/hermes/firmware", json_body={"Version": version, **metadata})
|
|
7564
|
+
typer.echo(f"Katalog kaydı açıldı: {version}")
|
|
7565
|
+
elif metadata:
|
|
7566
|
+
client.put(f"/hermes/firmware/{version}", json_body=metadata)
|
|
7523
7567
|
|
|
7524
|
-
|
|
7525
|
-
|
|
7568
|
+
typer.echo(f"Yükleniyor: {os.path.basename(file_path)} ({os.path.getsize(file_path)} byte, md5={md5_hex})")
|
|
7569
|
+
result = client.post_file(f"/hermes/firmware/{version}/file", file_path, data={"md5": md5_hex})
|
|
7570
|
+
typer.echo(f"Yüklendi: {result.get('File_Name')} ({result.get('File_Size')} byte, md5={result.get('Hash_MD5')})")
|
|
7526
7571
|
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
if existing is not None:
|
|
7531
|
-
result = client.put(f"/hermes/firmware/{version}", json_body=body)
|
|
7532
|
-
else:
|
|
7533
|
-
result = client.post("/hermes/firmware", json_body=body)
|
|
7572
|
+
if active is not None:
|
|
7573
|
+
client.put(f"/hermes/firmware/{version}", json_body={"Active": active})
|
|
7574
|
+
typer.echo(f"Active={active}")
|
|
7534
7575
|
|
|
7535
|
-
typer.echo(result.get("Message", result))
|
|
7536
7576
|
console.print()
|
|
7537
|
-
console.print(f"[bold]Gönder:[/bold] qapu fota ftp <device_id> --version {version}
|
|
7577
|
+
console.print(f"[bold]Gönder:[/bold] qapu fota ftp <device_id> --version {version}")
|
|
7538
7578
|
|
|
7539
7579
|
|
|
7540
7580
|
# Send a `fotahttp` MQTT Command - no --auto-retry Option, FOTA Commands
|
|
@@ -7575,9 +7615,9 @@ def fota_http(
|
|
|
7575
7615
|
@fota_app.command("ftp")
|
|
7576
7616
|
def fota_ftp(
|
|
7577
7617
|
device_id: str = typer.Argument(..., help="Target device ID."),
|
|
7578
|
-
server: str = typer.Option(
|
|
7579
|
-
user: str = typer.Option(
|
|
7580
|
-
password: Optional[str] = typer.Option(None, "--password", help="FTP password (max 23 chars) - falls back to QAPU_FTP_FOTA_PASSWORD if
|
|
7618
|
+
server: Optional[str] = typer.Option(None, "--server", help="FTP server host (max 31 chars). Omitted: the server's own default (firmware.ovoo.com.tr)."),
|
|
7619
|
+
user: Optional[str] = typer.Option(None, "--user", help="FTP username (max 23 chars). Omitted: the server's own download account (cinga_fota)."),
|
|
7620
|
+
password: Optional[str] = typer.Option(None, "--password", help="FTP password (max 23 chars) - falls back to QAPU_FTP_FOTA_PASSWORD; if neither is set the server fills its own (2026-10-27)."),
|
|
7581
7621
|
version: Optional[str] = typer.Option(None, "--version", help="Look up --firmware/--md5 from the firmware catalog by version (GET /hermes/firmware/{version}) instead of passing them by hand."),
|
|
7582
7622
|
firmware: Optional[str] = typer.Option(None, "--firmware", help="Firmware file name (max 23 chars) - first two characters must match the device's compiled hardware version. Required unless --version is given."),
|
|
7583
7623
|
md5: Optional[str] = typer.Option(None, "--md5", help="MD5 hex digest of the firmware file (32 hex characters). Required unless --version is given."),
|
|
@@ -7586,11 +7626,11 @@ def fota_ftp(
|
|
|
7586
7626
|
):
|
|
7587
7627
|
"""Send a fotaftp command over MQTT - the device downloads firmware via FTP (POST /hermes/iot/mqtt/fotaftp/{device_id}). No duplicate protection on the device side - never auto-retried. Real sends transparently go through the two-phase prepare/execute confirmation model server-side - this command still reads as one call."""
|
|
7588
7628
|
password = password or os.environ.get("QAPU_FTP_FOTA_PASSWORD")
|
|
7589
|
-
if not password:
|
|
7590
|
-
typer.echo("Error: pass --password, or set QAPU_FTP_FOTA_PASSWORD.", err=True)
|
|
7591
|
-
raise typer.Exit(code=1)
|
|
7592
7629
|
firmware, md5 = _resolve_firmware_fields(version, firmware, md5)
|
|
7593
|
-
body: dict[str, Any] = {"
|
|
7630
|
+
body: dict[str, Any] = {"firmware": firmware, "md5": md5}
|
|
7631
|
+
for key, value in (("server", server), ("user", user), ("password", password)):
|
|
7632
|
+
if value:
|
|
7633
|
+
body[key] = value
|
|
7594
7634
|
if mode is not None:
|
|
7595
7635
|
body["mode"] = mode
|
|
7596
7636
|
params: dict[str, Any] = {"dry_run": dry_run}
|
|
@@ -7606,6 +7646,53 @@ def fota_ftp(
|
|
|
7606
7646
|
typer.echo(f"Preview: {result['Preview']}")
|
|
7607
7647
|
|
|
7608
7648
|
|
|
7649
|
+
# Firmware Confirmation (2026-10-26, Firmware Confirmation Protocol 1) -
|
|
7650
|
+
# Approve a Version so its Trial Boots are Confirmed Automatically
|
|
7651
|
+
# (fwconfirm), and Watch What Happens per Boot
|
|
7652
|
+
@fota_app.command("approve")
|
|
7653
|
+
def fota_approve(version: str = typer.Argument(..., help="Catalog version, e.g. 01.00.01."), note: Optional[str] = typer.Option(None, "--note")):
|
|
7654
|
+
"""Approve a firmware version for automatic trial-boot confirmation (PUT /hermes/firmware-approvals/{version}). From now on every healthy trial boot of it gets a fwconfirm."""
|
|
7655
|
+
result = client.put(f"/hermes/firmware-approvals/{version}", json_body={"Approved": True, **({"Note": note} if note else {})})
|
|
7656
|
+
typer.echo(result.get("Message", result))
|
|
7657
|
+
|
|
7658
|
+
|
|
7659
|
+
@fota_app.command("revoke")
|
|
7660
|
+
def fota_revoke(version: str = typer.Argument(...), note: Optional[str] = typer.Option(None, "--note")):
|
|
7661
|
+
"""Revoke a version's confirmation approval - no further fwconfirm (never a rollback; that is a separate FOTA)."""
|
|
7662
|
+
result = client.put(f"/hermes/firmware-approvals/{version}", json_body={"Approved": False, **({"Note": note} if note else {})})
|
|
7663
|
+
typer.echo(result.get("Message", result))
|
|
7664
|
+
|
|
7665
|
+
|
|
7666
|
+
@fota_app.command("approvals")
|
|
7667
|
+
def fota_approvals(as_json: bool = typer.Option(False, "--json")):
|
|
7668
|
+
"""List versions approved (or revoked) for trial-boot confirmation (GET /hermes/firmware-approvals)."""
|
|
7669
|
+
items: list[dict[str, Any]] = client.get("/hermes/firmware-approvals")
|
|
7670
|
+
if as_json:
|
|
7671
|
+
typer.echo(json.dumps(items, indent=2))
|
|
7672
|
+
return
|
|
7673
|
+
_print_paginated(items, _simple_table_builder([("VERSION", "Version"), ("APPROVED", "Approved"), ("CHANNEL", "Channel"), ("APPROVED_AT", "Approved_At"), ("REVOKED_AT", "Revoked_At"), ("NOTE", "Note")]), limit=None, noun="sürüm")
|
|
7674
|
+
|
|
7675
|
+
|
|
7676
|
+
@fota_app.command("confirmations")
|
|
7677
|
+
def fota_confirmations(
|
|
7678
|
+
device_id: Optional[str] = typer.Option(None, "--device"),
|
|
7679
|
+
state: Optional[str] = typer.Option(None, "--state", help="waiting, sent, acked, confirmed, superseded, review"),
|
|
7680
|
+
limit: int = typer.Option(50, "--limit"),
|
|
7681
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
7682
|
+
):
|
|
7683
|
+
"""Trial boots and their confirmation state, newest first (GET /hermes/firmware-confirmations). --state review = retry limit hit."""
|
|
7684
|
+
params: dict[str, Any] = {"limit": limit}
|
|
7685
|
+
if device_id:
|
|
7686
|
+
params["device_id"] = device_id
|
|
7687
|
+
if state:
|
|
7688
|
+
params["state"] = state
|
|
7689
|
+
items: list[dict[str, Any]] = client.get("/hermes/firmware-confirmations", params=params)
|
|
7690
|
+
if as_json:
|
|
7691
|
+
typer.echo(json.dumps(items, indent=2))
|
|
7692
|
+
return
|
|
7693
|
+
_print_paginated(items, _simple_table_builder([("DEVICE", "Device_ID"), ("FIRMWARE", "Firmware"), ("TOKEN", "Token"), ("STATE", "State"), ("TRIES", "Attempts"), ("ACK", "Ack_Code"), ("LAST_SEEN", "Last_Seen_At"), ("NOTE", "Last_Error")]), limit=limit, noun="açılış")
|
|
7694
|
+
|
|
7695
|
+
|
|
7609
7696
|
# ------------------------------------------------------------
|
|
7610
7697
|
# `qapu console` Lived Here (2026-09-13/14) Until the TUI (`qapu tui`)
|
|
7611
7698
|
# Fully Superseded it (2026-10-12, Direct Request: "tui yaptıkdan sonra
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qapu-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.80.0
|
|
4
4
|
Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
|
|
5
5
|
Author: OVOO Technology
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|