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.
Files changed (47) hide show
  1. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/PKG-INFO +1 -1
  2. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/pyproject.toml +1 -1
  3. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/client.py +15 -0
  4. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/command_packages.py +7 -0
  5. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/main.py +153 -66
  6. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/PKG-INFO +1 -1
  7. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/README.md +0 -0
  8. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/__init__.py +0 -0
  9. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/__init__.py +0 -0
  10. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/app.py +0 -0
  11. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/app.tcss +0 -0
  12. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/data.py +0 -0
  13. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/rendering.py +0 -0
  14. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/__init__.py +0 -0
  15. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/catalog.py +0 -0
  16. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/catalog_form.py +0 -0
  17. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/catalog_picker.py +0 -0
  18. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/confirm.py +0 -0
  19. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/dashboard.py +0 -0
  20. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/device_authorization.py +0 -0
  21. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/device_edit.py +0 -0
  22. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/device_list.py +0 -0
  23. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/equipment_form.py +0 -0
  24. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/group_detail.py +0 -0
  25. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/group_form.py +0 -0
  26. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/group_list.py +0 -0
  27. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/screens/infrastructure.py +0 -0
  28. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/widgets/__init__.py +0 -0
  29. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/tui/widgets/device_table.py +0 -0
  30. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli/visibility.py +0 -0
  31. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/SOURCES.txt +0 -0
  32. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/dependency_links.txt +0 -0
  33. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/entry_points.txt +0 -0
  34. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/requires.txt +0 -0
  35. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/qapu_cli.egg-info/top_level.txt +0 -0
  36. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/setup.cfg +0 -0
  37. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_command_packages_complete.py +0 -0
  38. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_login.py +0 -0
  39. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_logout.py +0 -0
  40. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_authorization.py +0 -0
  41. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_catalog.py +0 -0
  42. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_device_edit.py +0 -0
  43. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_electrical_parent.py +0 -0
  44. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_groups.py +0 -0
  45. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_infrastructure.py +0 -0
  46. {qapu_cli-0.78.0 → qapu_cli-0.80.0}/tests/test_tui_smoke.py +0 -0
  47. {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.78.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.78.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 U%", justify="right")
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
- _colorize_motor_stress(row.get("Motor_Stress_U_Nema_Pct")),
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.", "Data_Reliability_Completeness_Score"),
1494
- ("MOTOR U%", "Motor_Stress_U_Nema_Pct"),
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üç Dönüşümü", _format_score_value(row.get("Load_Power_Conversion_Score"))),
1729
- ("Rejim Kararlılığı", _format_score_value(row.get("Load_Regime_Stability_Score"))),
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
- ("Tamlık", "Data_Reliability_Completeness_Score"), ("Güncellik", "Data_Reliability_Timeliness_Score"),
1739
- ("Tutarlılık", "Data_Reliability_Consistency_Score"), ("İzlenebilirlik", "Data_Reliability_Traceability_Score"),
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ı", _motor_stress_color(row.get("Motor_Stress_U_Nema_Pct")), "U_NEMA %", row.get("Motor_Stress_U_Nema_Pct"), previous.get("Motor_Stress_U_Nema_Pct"), [
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"), ("STATUS", "Status")]), limit=limit, noun="SIM")
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 - deactivate it with `qapu sim update ICCID --inactive`."""
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 path to the firmware file to publish."),
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 real firmware file end to end: compute its MD5, upload it to the firmware FTP server as cinga_ftp_admin, then add/update the version's catalog entry with the real file metadata. Needs QAPU_FTP_ADMIN_PASSWORD set."""
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
- # Resolve the FTP Admin Password - Same env-var Convention as QAPU_HERMES_KEY, Never Hardcoded
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(8192), b""):
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
- typer.echo(f"Yükleniyor: {file_name} ({file_size} byte, md5={md5_hex}) -> {ftp_host}")
7510
-
7511
- # Upload via FTP - the cinga_ftp_admin Account, Read-Write Home =
7512
- # /data/shared/firmware (see deploy/ops/ftp/docker-compose.yml)
7513
- try:
7514
- with ftplib.FTP(ftp_host, timeout=30) as ftp:
7515
- ftp.login("cinga_ftp_admin", ftp_password)
7516
- with open(file_path, "rb") as handle:
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
- body = _firmware_field_body(title, description, channel, active, file_name, file_size, file_url, md5_hex, None, None, release_notes)
7525
- body["Version"] = version
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
- # Add if This Version is New, Update if it Already Exists - get_or_none()
7528
- # Treats a 404 as a Normal "Not Yet Registered" Outcome, not an Error
7529
- existing = client.get_or_none(f"/hermes/firmware/{version}")
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} (veya: qapu fota http <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(_FIRMWARE_SERVER_DEFAULT, "--server", help="FTP server host (max 31 chars)."),
7579
- user: str = typer.Option("cinga_fota", "--user", help="FTP username (max 23 chars) - defaults to the standing read-only device account."),
7580
- password: Optional[str] = typer.Option(None, "--password", help="FTP password (max 23 chars) - falls back to QAPU_FTP_FOTA_PASSWORD if not given."),
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] = {"server": server, "user": user, "password": password, "firmware": firmware, "md5": md5}
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.78.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