qapu-cli 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qapu-cli
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -49,6 +49,7 @@ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`
49
49
  ## Commands
50
50
 
51
51
  ```bash
52
+ qapu --version # print the installed qapu-cli version and exit
52
53
  qapu health # GET /health - no auth, quick connectivity check
53
54
  qapu device list # GET /hermes/devices - every device, bulk, as a table
54
55
  qapu device list --json # same data, raw JSON
@@ -76,6 +77,18 @@ qapu data <device_id> --search vrms # substring match on variable ID, e.g. VRMS
76
77
 
77
78
  `qapu data`'s family filters (`--energy`/`--gsm`/`--voltage`/`--current`/`--battery`) are a union - passing more than one shows variables matching any of them. `--search` narrows whatever they leave (or the full list, if none were given) further, by a case-insensitive substring match on the variable ID - the way to pin down an exact family like `VRMS_R`/`VRMS_S`/`VRMS_T`. Values come from the measurement cache's rolling buffer (the same source `/measurement/{device_id}/last/{variable_id}` reads from), not a fresh device poll - "latest" means the most recent packet already ingested, not real-time.
78
79
 
80
+ ```bash
81
+ qapu trend <device_id> <variable_id> # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
82
+ qapu trend <device_id> <variable_id> --last 50 # last N raw readings instead of the default 20
83
+ qapu trend <device_id> <variable_id> --days 2 # daily min/avg/max over the last 2 days, instead of raw readings
84
+ qapu trend <device_id> <variable_id> --days 30 # ...or the last month
85
+ qapu trend <device_id> <variable_id> --json
86
+ ```
87
+
88
+ `qapu trend` shows a per-variable history plus a simple linear-regression trend line (slope + direction). Two modes, picked by which option you pass:
89
+ - **Raw readings** (`--last N`, default `N=20`): the measurement cache's rolling buffer - bounded to its own depth (~50 most recent packets), *not* a time window. Good for "what's it doing right now."
90
+ - **Daily aggregates** (`--days N`): one row per calendar day (min/avg/max/count), from the same cache the `/measurement/{device_id}/history` endpoint reads - this is what actually covers multi-day/month windows, since the raw buffer doesn't go back that far. `--days` wins if both are given.
91
+
79
92
  Filtering (`--status`/`--model`/`--limit`) happens client-side in the CLI, not on the server - fine at the current fleet size, worth moving server-side (`GET /hermes/devices?status=...`) if it ever grows large enough to matter.
80
93
 
81
94
  ## Auth (current placeholder - read this before pointing at production)
@@ -37,6 +37,7 @@ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`
37
37
  ## Commands
38
38
 
39
39
  ```bash
40
+ qapu --version # print the installed qapu-cli version and exit
40
41
  qapu health # GET /health - no auth, quick connectivity check
41
42
  qapu device list # GET /hermes/devices - every device, bulk, as a table
42
43
  qapu device list --json # same data, raw JSON
@@ -64,6 +65,18 @@ qapu data <device_id> --search vrms # substring match on variable ID, e.g. VRMS
64
65
 
65
66
  `qapu data`'s family filters (`--energy`/`--gsm`/`--voltage`/`--current`/`--battery`) are a union - passing more than one shows variables matching any of them. `--search` narrows whatever they leave (or the full list, if none were given) further, by a case-insensitive substring match on the variable ID - the way to pin down an exact family like `VRMS_R`/`VRMS_S`/`VRMS_T`. Values come from the measurement cache's rolling buffer (the same source `/measurement/{device_id}/last/{variable_id}` reads from), not a fresh device poll - "latest" means the most recent packet already ingested, not real-time.
66
67
 
68
+ ```bash
69
+ qapu trend <device_id> <variable_id> # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
70
+ qapu trend <device_id> <variable_id> --last 50 # last N raw readings instead of the default 20
71
+ qapu trend <device_id> <variable_id> --days 2 # daily min/avg/max over the last 2 days, instead of raw readings
72
+ qapu trend <device_id> <variable_id> --days 30 # ...or the last month
73
+ qapu trend <device_id> <variable_id> --json
74
+ ```
75
+
76
+ `qapu trend` shows a per-variable history plus a simple linear-regression trend line (slope + direction). Two modes, picked by which option you pass:
77
+ - **Raw readings** (`--last N`, default `N=20`): the measurement cache's rolling buffer - bounded to its own depth (~50 most recent packets), *not* a time window. Good for "what's it doing right now."
78
+ - **Daily aggregates** (`--days N`): one row per calendar day (min/avg/max/count), from the same cache the `/measurement/{device_id}/history` endpoint reads - this is what actually covers multi-day/month windows, since the raw buffer doesn't go back that far. `--days` wins if both are given.
79
+
67
80
  Filtering (`--status`/`--model`/`--limit`) happens client-side in the CLI, not on the server - fine at the current fleet size, worth moving server-side (`GET /hermes/devices?status=...`) if it ever grows large enough to matter.
68
81
 
69
82
  ## Auth (current placeholder - read this before pointing at production)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qapu-cli"
3
- version = "0.3.0"
3
+ version = "0.4.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"
@@ -5,6 +5,7 @@
5
5
 
6
6
  import json
7
7
  from datetime import datetime, timezone
8
+ from importlib.metadata import PackageNotFoundError, version as _package_version
8
9
  from typing import Any, Optional
9
10
 
10
11
  import httpx
@@ -22,6 +23,33 @@ app.add_typer(variable_app, name="variable")
22
23
 
23
24
  console = Console()
24
25
 
26
+
27
+ # --version - Reads the Installed Package Metadata Directly, so it's
28
+ # Always in Sync with pyproject.toml's version - Nothing to Hand-Bump Here.
29
+ def _version_callback(show_version: bool) -> None:
30
+
31
+ # Only Act if --version was Actually Passed
32
+ if not show_version:
33
+ return
34
+
35
+ # Try to Resolve the Installed Version
36
+ try:
37
+ typer.echo(_package_version("qapu-cli"))
38
+
39
+ # Not Installed as a Package (e.g. Running Straight from Source)
40
+ except PackageNotFoundError:
41
+ typer.echo("unknown (not installed as a package)")
42
+
43
+ # Exit Before Typer Falls Through to "No Command Given" Help Text
44
+ raise typer.Exit()
45
+
46
+
47
+ @app.callback()
48
+ def _root(
49
+ version: Optional[bool] = typer.Option(None, "--version", callback=_version_callback, is_eager=True, help="Show the installed qapu-cli version and exit."),
50
+ ) -> None:
51
+ """Qapu API client CLI."""
52
+
25
53
  # Segment Name -> ID, Matching Constants.Variable_Segment (common/qapu_common/constants.py)
26
54
  SEGMENTS = {
27
55
  "unknown": 0,
@@ -544,6 +572,173 @@ def device_data(
544
572
  console.print(_build_data_table(sorted(readings, key=lambda r: r.get("Variable_ID") or "")))
545
573
 
546
574
 
575
+ # Simple Least-Squares Slope - No Extra Dependency for This, and n < 2
576
+ # (Or a Vertical/Degenerate x Range) Just Means "Not Enough Points Yet"
577
+ def _linear_slope(xs: list[float], ys: list[float]) -> Optional[float]:
578
+
579
+ # Not Enough Points to Fit a Line
580
+ n = len(xs)
581
+ if n < 2:
582
+ return None
583
+
584
+ # Compute Means
585
+ mean_x = sum(xs) / n
586
+ mean_y = sum(ys) / n
587
+
588
+ # Compute Slope via Covariance / Variance
589
+ numerator = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
590
+ denominator = sum((x - mean_x) ** 2 for x in xs)
591
+
592
+ # Degenerate x Range (All Same Timestamp/Day) - No Meaningful Slope
593
+ if denominator == 0:
594
+ return None
595
+
596
+ # Return the Slope
597
+ return numerator / denominator
598
+
599
+
600
+ # Describe a Slope's Direction in Turkish, for the Trend Summary Line
601
+ def _slope_direction(slope: float) -> str:
602
+
603
+ # Resolve Direction Label
604
+ if slope > 0:
605
+ return "artıyor"
606
+ if slope < 0:
607
+ return "azalıyor"
608
+ return "sabit"
609
+
610
+
611
+ # Get One Variable's Trend on One Device - Either the Last N Raw Buffered
612
+ # Readings, or Daily Min/Avg/Max Over the Last N Days (Mutually Exclusive -
613
+ # --days Wins if Both are Given)
614
+ @app.command("trend")
615
+ def device_trend(
616
+ device_id: str = typer.Argument(..., help="Device ID."),
617
+ variable_id: str = typer.Argument(..., help="Variable ID (e.g. VRMS_R)."),
618
+ last: int = typer.Option(20, "--last", help="Show the last N raw buffered readings. Bounded by the buffer's own depth (~50 most recent packets) - not a time window."),
619
+ days: Optional[int] = typer.Option(None, "--days", help="Show daily min/avg/max over the last N days instead of raw readings (e.g. --days 2, --days 30 for a month). Takes priority over --last if both are given."),
620
+ as_json: bool = typer.Option(False, "--json", help="Print raw JSON instead of a table."),
621
+ ):
622
+ """Trend for one variable on one device: recent readings or daily min/avg/max (GET /hermes/trend/{device_id}/{variable_id})."""
623
+
624
+ # Fetch the Trend - Raw Series + Full Daily History, Already Enriched
625
+ t: dict[str, Any] = client.get(f"/hermes/trend/{device_id}/{variable_id}")
626
+
627
+ # JSON Output - Everything at Once, no Slicing/Stats Applied
628
+ if as_json:
629
+
630
+ # Print Raw JSON
631
+ typer.echo(json.dumps(t, indent=2))
632
+ return
633
+
634
+ # Header Line - What Variable, on What Device
635
+ description = t.get("Description") or "-"
636
+ unit = t.get("Unit") or "-"
637
+ segment_name = t.get("Segment") or "-"
638
+ console.print(f"[bold]{t.get('Variable_ID') or variable_id}[/bold] - {description} ({unit}) - {segment_name}")
639
+
640
+ # --- Daily Mode (--days) ---
641
+ if days is not None:
642
+
643
+ # Resolve the Last N Days, Sorted Chronologically
644
+ daily: dict[str, Any] = t.get("Daily") or {}
645
+ sorted_days = sorted(daily.keys())[-days:]
646
+
647
+ # Nothing to Show
648
+ if not sorted_days:
649
+ typer.echo("Bu değişken için günlük veri yok.")
650
+ return
651
+
652
+ # Build the Daily Table
653
+ table = Table(show_header=True, header_style="bold")
654
+ table.add_column("DATE")
655
+ table.add_column("MIN", justify="right")
656
+ table.add_column("AVG", justify="right")
657
+ table.add_column("MAX", justify="right")
658
+ table.add_column("COUNT", justify="right")
659
+
660
+ # Populate Rows, Collecting (Day Index, Avg) Pairs for the Slope
661
+ xs: list[float] = []
662
+ ys: list[float] = []
663
+ for i, day in enumerate(sorted_days):
664
+
665
+ # Resolve the Day's Node
666
+ node = daily.get(day) or {}
667
+ avg = node.get("Avg")
668
+
669
+ # Add the Row
670
+ table.add_row(
671
+ day,
672
+ f"{node.get('Min'):g}" if isinstance(node.get("Min"), (int, float)) else "-",
673
+ f"{avg:g}" if isinstance(avg, (int, float)) else "-",
674
+ f"{node.get('Max'):g}" if isinstance(node.get("Max"), (int, float)) else "-",
675
+ str(node.get("Count", "-")),
676
+ )
677
+
678
+ # Track for the Slope (Trend Over the Daily Averages)
679
+ if isinstance(avg, (int, float)):
680
+ xs.append(float(i))
681
+ ys.append(float(avg))
682
+
683
+ # Print the Table
684
+ console.print(table)
685
+
686
+ # Print the Trend Summary, if There Were Enough Points
687
+ slope = _linear_slope(xs, ys)
688
+ if slope is not None:
689
+ typer.echo(f"Trend: {slope:+.4f} {unit}/gün ({_slope_direction(slope)})")
690
+
691
+ return
692
+
693
+ # --- Raw Readings Mode (Default, --last) ---
694
+ series: list = t.get("Series") or []
695
+ series = series[-last:] if last > 0 else series
696
+
697
+ # Nothing Buffered Yet
698
+ if not series:
699
+ typer.echo("Bu değişken için henüz buffer verisi yok.")
700
+ return
701
+
702
+ # Build the Readings Table, Chronological (Oldest First)
703
+ table = Table(show_header=True, header_style="bold")
704
+ table.add_column("TIME")
705
+ table.add_column("VALUE", justify="right")
706
+
707
+ # Populate Rows, Collecting (Elapsed Hours, Value) Pairs for the Slope
708
+ xs = []
709
+ ys = []
710
+ first_time: Optional[datetime] = None
711
+ for entry in series:
712
+
713
+ # Skip Malformed Entries Defensively
714
+ if not isinstance(entry, list) or len(entry) != 2:
715
+ continue
716
+
717
+ # Unpack the Entry
718
+ raw_time, value = entry
719
+ table.add_row(_relative_time(raw_time), f"{value:g}" if isinstance(value, (int, float)) else "-")
720
+
721
+ # Track for the Slope (Trend Over Elapsed Hours Since the First Point)
722
+ try:
723
+ parsed_time = datetime.fromisoformat(raw_time)
724
+ if first_time is None:
725
+ first_time = parsed_time
726
+ xs.append((parsed_time - first_time).total_seconds() / 3600)
727
+ ys.append(float(value))
728
+ except (ValueError, TypeError):
729
+ continue
730
+
731
+ # Print the Table
732
+ console.print(table)
733
+
734
+ # Print Min/Avg/Max and the Trend Summary
735
+ if ys:
736
+ typer.echo(f"Min: {min(ys):g} Avg: {sum(ys) / len(ys):g} Max: {max(ys):g}")
737
+ slope = _linear_slope(xs, ys)
738
+ if slope is not None:
739
+ typer.echo(f"Trend: {slope:+.4f} {unit}/saat ({_slope_direction(slope)})")
740
+
741
+
547
742
  if __name__ == "__main__":
548
743
 
549
744
  # Run the Typer App
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qapu-cli
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -49,6 +49,7 @@ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`
49
49
  ## Commands
50
50
 
51
51
  ```bash
52
+ qapu --version # print the installed qapu-cli version and exit
52
53
  qapu health # GET /health - no auth, quick connectivity check
53
54
  qapu device list # GET /hermes/devices - every device, bulk, as a table
54
55
  qapu device list --json # same data, raw JSON
@@ -76,6 +77,18 @@ qapu data <device_id> --search vrms # substring match on variable ID, e.g. VRMS
76
77
 
77
78
  `qapu data`'s family filters (`--energy`/`--gsm`/`--voltage`/`--current`/`--battery`) are a union - passing more than one shows variables matching any of them. `--search` narrows whatever they leave (or the full list, if none were given) further, by a case-insensitive substring match on the variable ID - the way to pin down an exact family like `VRMS_R`/`VRMS_S`/`VRMS_T`. Values come from the measurement cache's rolling buffer (the same source `/measurement/{device_id}/last/{variable_id}` reads from), not a fresh device poll - "latest" means the most recent packet already ingested, not real-time.
78
79
 
80
+ ```bash
81
+ qapu trend <device_id> <variable_id> # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
82
+ qapu trend <device_id> <variable_id> --last 50 # last N raw readings instead of the default 20
83
+ qapu trend <device_id> <variable_id> --days 2 # daily min/avg/max over the last 2 days, instead of raw readings
84
+ qapu trend <device_id> <variable_id> --days 30 # ...or the last month
85
+ qapu trend <device_id> <variable_id> --json
86
+ ```
87
+
88
+ `qapu trend` shows a per-variable history plus a simple linear-regression trend line (slope + direction). Two modes, picked by which option you pass:
89
+ - **Raw readings** (`--last N`, default `N=20`): the measurement cache's rolling buffer - bounded to its own depth (~50 most recent packets), *not* a time window. Good for "what's it doing right now."
90
+ - **Daily aggregates** (`--days N`): one row per calendar day (min/avg/max/count), from the same cache the `/measurement/{device_id}/history` endpoint reads - this is what actually covers multi-day/month windows, since the raw buffer doesn't go back that far. `--days` wins if both are given.
91
+
79
92
  Filtering (`--status`/`--model`/`--limit`) happens client-side in the CLI, not on the server - fine at the current fleet size, worth moving server-side (`GET /hermes/devices?status=...`) if it ever grows large enough to matter.
80
93
 
81
94
  ## Auth (current placeholder - read this before pointing at production)
File without changes
File without changes
File without changes