qapu-cli 0.7.2__tar.gz → 0.7.4__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.7.2
3
+ Version: 0.7.4
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
@@ -56,10 +56,10 @@ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`
56
56
  |---|---|
57
57
  | `qapu ct-check` | Diagnose CT (current transformer) wiring per phase - reversed polarity vs. wrong phase order - from live PF_R/S/T/AE_R/S/T (GET /hermes/data/{device_id}). |
58
58
  | `qapu current` | Current synthesis parameters, grouped (RMS, imbalance, THD, crest factor, fundamental component) (GET /hermes/data/{device_id}). |
59
- | `qapu data` | Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), or an explicit --start/--end range (GET /hermes/data/{device_id}). |
59
+ | `qapu data` | Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), an explicit --start/--end range, or a live-refreshed view (--watch) (GET /hermes/data/{device_id}). |
60
60
  | `qapu energy` | One day's active/reactive energy breakdown per phase (GET /hermes/energy/{device_id}). |
61
61
  | `qapu health` | Check CLI/server version, connectivity, and server status (GET /health, no auth required). |
62
- | `qapu pipeline` | Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats). |
62
+ | `qapu pipeline` | Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats). `--watch` live-refreshes in place. |
63
63
  | `qapu power` | Power synthesis parameters, grouped (active/reactive/apparent, imbalance, power factor) (GET /hermes/data/{device_id}). |
64
64
  | `qapu timeline` | Get one device's timeline events (GET /hermes/timeline/{device_id}). |
65
65
  | `qapu trend` | Trend for one variable on one device: recent readings, daily min/avg/max, or an explicit --start/--end range (GET /hermes/trend/{device_id}/{variable_id}). |
@@ -319,6 +319,14 @@ qapu data <device_id> --start 2026-08-30 --end 2026-08-31 --days 5 # daily mi
319
319
 
320
320
  **Refined again same day, per three more rounds of direct feedback**: (1) `--last`'s TIME column showed a coarse relative bucket ("1 gün önce") for every one of the N readings - useless once several readings from the same day all collapse to the same bucket. Added a new `_format_time()` helper (`tools/cli/qapu_cli/main.py`, next to the existing `_relative_time()`) that renders the actual data timestamp as `YYYY-MM-DD HH:MM:SS` instead - used only where each row needs its own real time (this per-reading table); `_relative_time()` itself (SIM last-connection, timeline, etc.) is untouched, still relative, since "how long ago" is the right framing there. (2) The per-variable group tables were initially borderless (`box=None`) for a lighter look - reverted to the same bordered `Table()` style every other CLI table already uses ("görsel kısmı güzelleştir tablolu yap"), since the borderless version read as less like a real table, not more. `--json` output is untouched either way ("--json diyince ai kendine göre okur onu" - raw JSON is for machine consumption, formatting choices don't need to accommodate it). (3) The `VARIABLE_ID — Description (Unit)` heading was originally a plain `console.print()` line sitting above each group's table, not visually part of it - moved onto the `Table` itself via Rich's `title=`/`title_justify="left"`/`title_style="bold"` params, so it now renders attached to the table's own border ("kısmını da tabloya dahil edelim tablo yapısı ile görünsün"). Live-tested against real prod data: `qapu data <id> --search VRMS --last 5` now shows 5 grouped, bordered, titled tables with real per-reading timestamps (e.g. `2026-09-01 03:12:14`).
321
321
 
322
+ **`qapu data --watch`/`-w` (added 2026-09-04)** - the same live-refresh idea `qapu pipeline --watch` uses (see that command's own entry further down for the underlying pattern), applied to a single device's latest readings instead of the fleet-wide pipeline summary, per a same-day follow-up ("aynı şekilde data izlerken de bir canlı uç açabilirmiyiz"):
323
+ ```bash
324
+ qapu data <device_id> --watch # every variable, live
325
+ qapu data <device_id> --watch --search VRMS # combine with family/search filters, same as normal mode
326
+ qapu data <device_id> --watch --interval 2 # poll every 2s instead of the default 5s
327
+ ```
328
+ Only valid for the default latest-value mode - refuses clearly if combined with `--last`/`--days`/`--start`/`--end`/`--json`. Filtering logic (`--energy`/`--gsm`/`--voltage`/`--current`/`--battery`/`--search`) was extracted into a shared `_apply_data_filters()` so the watch loop and the normal path can't drift apart. Each poll after the first shows a `DEĞİŞİM` column - green `▲ <diff>` if a variable rose since the last poll, red `▼ <diff>` if it fell, `·` if unchanged - and `ZAMAN` shows the absolute reading timestamp (`_format_time()`, not `_relative_time()` - a 5-second poll loop would show "0 dk önce" for every row regardless of whether it just changed, same reasoning already applied to `--last`'s per-reading table above). Verified the same way as `pipeline --watch`'s delta logic: `_build_data_watch_table()` called directly with two synthetic consecutive polls (one variable rising, one falling, one flat), confirmed correct `▲`/`▼`/`·` rendering - `rich.Live`'s actual screen-redraw behavior wasn't separately re-verified end to end here either, for the same non-TTY sandbox reason noted under `pipeline --watch`.
329
+
322
330
  ```bash
323
331
  qapu trend <device_id> <variable_id> # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
324
332
  qapu trend <device_id> <variable_id> --last 50 # last N raw readings instead of the default 20
@@ -368,6 +376,8 @@ qapu pipeline --json
368
376
  ```
369
377
  Not device-scoped - a fleet-wide summary across every service in the `ingest -> stream -> calibration -> raw-writer -> synthesis -> ... -> rule` pipeline. Deliberately reads from the real tables each stage actually writes to (`raw_data.stream_time`/`valid_pack` for `hardware`'s ingest, `streams.stream_time` for `data`'s calibration+synthesis completion, `device_timeline.create_time` for `rule`'s observable output, `blockchain.create_time` for mined blocks) rather than any service's own `/health` endpoint - those are point-in-time snapshots (uptime, CPU, last-message-age), not a windowed throughput count. New `common/qapu_common/domain/pipeline.py` (`Pipeline_Stats.get_stats(minutes)`, four plain `SELECT count(*) ... WHERE <time column> >= now() - interval` queries, no caching - these are meant to be live) + `GET /hermes/pipeline/stats` (`services/api/src/routers/hermes.py`, `minutes` query param, 1 to 10080/7 days). **`Rule_Events` deliberately does NOT count rule evaluations** - `rule` evaluates every single stream message 1:1, the same count `Data_Processed` already reports, so repeating it would be redundant; it counts `device_timeline` rows instead, rule's actual observable output (a trigger/reset transition), not every evaluation pass. The CLI prints a bordered summary table plus a soft backlog hint (yellow note, not an error) if `Data_Processed` looks meaningfully behind `Ingest_Valid` (`<80%`) - a sign of consumer lag worth checking, not itself proof of one. Live-tested against real prod DB: `--minutes 60` correctly returned all zeros (no test traffic in the preceding hour at that point in the session), `--minutes 1440` correctly returned real 24h counts, and a fresh real packet sent immediately before a `--minutes 5` check showed up as exactly `+1` across Ingest/Data_Processed/Blockchain_Mined (Rule_Events stayed `0`, correctly - that packet's only variable, `PCB_T`, didn't cross any rule threshold).
370
378
 
379
+ **`--watch`/`-w` added the same day**, per direct follow-up request ("pipe line canlı takip edilebilecek bir tool yapabilirmiyiz") - polls the same endpoint every `--interval` seconds (default `5.0`) and redraws the summary in place via Rich's `Live`, instead of printing once and exiting; `Ctrl+C` stops cleanly. Each poll after the first also shows a `DEĞİŞİM` column - green `+N` if a stage's count grew since the last poll, red `-N` if it somehow shrank (shouldn't happen for these monotonic counters within a fixed window, but rendered anyway rather than assumed impossible), `·` if unchanged - a bare redrawn count doesn't read as "live" the way a visible delta does. Refactored the table-building logic into a shared `_build_pipeline_group()` (title + table + optional backlog note, returned as one `rich.console.Group`) used by both the one-shot path and the `--watch` loop, so they can't drift apart. Not combinable with `--json` (a live-refreshed raw-JSON dump isn't a coherent thing to build - errors clearly if both are passed). The delta/redraw logic itself was verified directly (calling `_build_pipeline_group()` with two synthetic consecutive polls and confirming the `+4`/`+2`/`·` rendering, plus the empty-result error path) rather than through a full `--watch` session end to end - `rich.Live`'s screen-redraw behavior depends on `console.is_terminal`, which this environment's piped/non-TTY test harness can't exercise the same way a real interactive terminal does; confirm the actual live redraw looks right in a real terminal session if this is touched again.
380
+
371
381
  `qapu trend` shows a per-variable history plus a simple linear-regression trend line (slope + direction). Three modes, picked by which options you pass:
372
382
  - **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."
373
383
  - **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 over `--last` if both are given (and `--start`/`--end` isn't).
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: qapu-cli
3
- Version: 0.7.2
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
- Author: OVOO Technology
6
- Classifier: Programming Language :: Python :: 3
7
- Requires-Python: >=3.10
8
- Description-Content-Type: text/markdown
9
- Requires-Dist: typer<1.0,>=0.12
10
- Requires-Dist: httpx<1.0,>=0.27
11
- Requires-Dist: rich<16.0,>=13.0
12
-
13
1
  # Qapu CLI
14
2
 
15
3
  A thin command-line client for the Qapu API (`api.ovoo.com.tr`), built for the Hermes agent (runs outside this Swarm, in a separate datacenter, and only ever talks to Qapu through this public API) - but usable by anything else that needs to script against Qapu from outside the private network.
@@ -56,10 +44,10 @@ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`
56
44
  |---|---|
57
45
  | `qapu ct-check` | Diagnose CT (current transformer) wiring per phase - reversed polarity vs. wrong phase order - from live PF_R/S/T/AE_R/S/T (GET /hermes/data/{device_id}). |
58
46
  | `qapu current` | Current synthesis parameters, grouped (RMS, imbalance, THD, crest factor, fundamental component) (GET /hermes/data/{device_id}). |
59
- | `qapu data` | Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), or an explicit --start/--end range (GET /hermes/data/{device_id}). |
47
+ | `qapu data` | Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), an explicit --start/--end range, or a live-refreshed view (--watch) (GET /hermes/data/{device_id}). |
60
48
  | `qapu energy` | One day's active/reactive energy breakdown per phase (GET /hermes/energy/{device_id}). |
61
49
  | `qapu health` | Check CLI/server version, connectivity, and server status (GET /health, no auth required). |
62
- | `qapu pipeline` | Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats). |
50
+ | `qapu pipeline` | Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats). `--watch` live-refreshes in place. |
63
51
  | `qapu power` | Power synthesis parameters, grouped (active/reactive/apparent, imbalance, power factor) (GET /hermes/data/{device_id}). |
64
52
  | `qapu timeline` | Get one device's timeline events (GET /hermes/timeline/{device_id}). |
65
53
  | `qapu trend` | Trend for one variable on one device: recent readings, daily min/avg/max, or an explicit --start/--end range (GET /hermes/trend/{device_id}/{variable_id}). |
@@ -319,6 +307,14 @@ qapu data <device_id> --start 2026-08-30 --end 2026-08-31 --days 5 # daily mi
319
307
 
320
308
  **Refined again same day, per three more rounds of direct feedback**: (1) `--last`'s TIME column showed a coarse relative bucket ("1 gün önce") for every one of the N readings - useless once several readings from the same day all collapse to the same bucket. Added a new `_format_time()` helper (`tools/cli/qapu_cli/main.py`, next to the existing `_relative_time()`) that renders the actual data timestamp as `YYYY-MM-DD HH:MM:SS` instead - used only where each row needs its own real time (this per-reading table); `_relative_time()` itself (SIM last-connection, timeline, etc.) is untouched, still relative, since "how long ago" is the right framing there. (2) The per-variable group tables were initially borderless (`box=None`) for a lighter look - reverted to the same bordered `Table()` style every other CLI table already uses ("görsel kısmı güzelleştir tablolu yap"), since the borderless version read as less like a real table, not more. `--json` output is untouched either way ("--json diyince ai kendine göre okur onu" - raw JSON is for machine consumption, formatting choices don't need to accommodate it). (3) The `VARIABLE_ID — Description (Unit)` heading was originally a plain `console.print()` line sitting above each group's table, not visually part of it - moved onto the `Table` itself via Rich's `title=`/`title_justify="left"`/`title_style="bold"` params, so it now renders attached to the table's own border ("kısmını da tabloya dahil edelim tablo yapısı ile görünsün"). Live-tested against real prod data: `qapu data <id> --search VRMS --last 5` now shows 5 grouped, bordered, titled tables with real per-reading timestamps (e.g. `2026-09-01 03:12:14`).
321
309
 
310
+ **`qapu data --watch`/`-w` (added 2026-09-04)** - the same live-refresh idea `qapu pipeline --watch` uses (see that command's own entry further down for the underlying pattern), applied to a single device's latest readings instead of the fleet-wide pipeline summary, per a same-day follow-up ("aynı şekilde data izlerken de bir canlı uç açabilirmiyiz"):
311
+ ```bash
312
+ qapu data <device_id> --watch # every variable, live
313
+ qapu data <device_id> --watch --search VRMS # combine with family/search filters, same as normal mode
314
+ qapu data <device_id> --watch --interval 2 # poll every 2s instead of the default 5s
315
+ ```
316
+ Only valid for the default latest-value mode - refuses clearly if combined with `--last`/`--days`/`--start`/`--end`/`--json`. Filtering logic (`--energy`/`--gsm`/`--voltage`/`--current`/`--battery`/`--search`) was extracted into a shared `_apply_data_filters()` so the watch loop and the normal path can't drift apart. Each poll after the first shows a `DEĞİŞİM` column - green `▲ <diff>` if a variable rose since the last poll, red `▼ <diff>` if it fell, `·` if unchanged - and `ZAMAN` shows the absolute reading timestamp (`_format_time()`, not `_relative_time()` - a 5-second poll loop would show "0 dk önce" for every row regardless of whether it just changed, same reasoning already applied to `--last`'s per-reading table above). Verified the same way as `pipeline --watch`'s delta logic: `_build_data_watch_table()` called directly with two synthetic consecutive polls (one variable rising, one falling, one flat), confirmed correct `▲`/`▼`/`·` rendering - `rich.Live`'s actual screen-redraw behavior wasn't separately re-verified end to end here either, for the same non-TTY sandbox reason noted under `pipeline --watch`.
317
+
322
318
  ```bash
323
319
  qapu trend <device_id> <variable_id> # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
324
320
  qapu trend <device_id> <variable_id> --last 50 # last N raw readings instead of the default 20
@@ -368,6 +364,8 @@ qapu pipeline --json
368
364
  ```
369
365
  Not device-scoped - a fleet-wide summary across every service in the `ingest -> stream -> calibration -> raw-writer -> synthesis -> ... -> rule` pipeline. Deliberately reads from the real tables each stage actually writes to (`raw_data.stream_time`/`valid_pack` for `hardware`'s ingest, `streams.stream_time` for `data`'s calibration+synthesis completion, `device_timeline.create_time` for `rule`'s observable output, `blockchain.create_time` for mined blocks) rather than any service's own `/health` endpoint - those are point-in-time snapshots (uptime, CPU, last-message-age), not a windowed throughput count. New `common/qapu_common/domain/pipeline.py` (`Pipeline_Stats.get_stats(minutes)`, four plain `SELECT count(*) ... WHERE <time column> >= now() - interval` queries, no caching - these are meant to be live) + `GET /hermes/pipeline/stats` (`services/api/src/routers/hermes.py`, `minutes` query param, 1 to 10080/7 days). **`Rule_Events` deliberately does NOT count rule evaluations** - `rule` evaluates every single stream message 1:1, the same count `Data_Processed` already reports, so repeating it would be redundant; it counts `device_timeline` rows instead, rule's actual observable output (a trigger/reset transition), not every evaluation pass. The CLI prints a bordered summary table plus a soft backlog hint (yellow note, not an error) if `Data_Processed` looks meaningfully behind `Ingest_Valid` (`<80%`) - a sign of consumer lag worth checking, not itself proof of one. Live-tested against real prod DB: `--minutes 60` correctly returned all zeros (no test traffic in the preceding hour at that point in the session), `--minutes 1440` correctly returned real 24h counts, and a fresh real packet sent immediately before a `--minutes 5` check showed up as exactly `+1` across Ingest/Data_Processed/Blockchain_Mined (Rule_Events stayed `0`, correctly - that packet's only variable, `PCB_T`, didn't cross any rule threshold).
370
366
 
367
+ **`--watch`/`-w` added the same day**, per direct follow-up request ("pipe line canlı takip edilebilecek bir tool yapabilirmiyiz") - polls the same endpoint every `--interval` seconds (default `5.0`) and redraws the summary in place via Rich's `Live`, instead of printing once and exiting; `Ctrl+C` stops cleanly. Each poll after the first also shows a `DEĞİŞİM` column - green `+N` if a stage's count grew since the last poll, red `-N` if it somehow shrank (shouldn't happen for these monotonic counters within a fixed window, but rendered anyway rather than assumed impossible), `·` if unchanged - a bare redrawn count doesn't read as "live" the way a visible delta does. Refactored the table-building logic into a shared `_build_pipeline_group()` (title + table + optional backlog note, returned as one `rich.console.Group`) used by both the one-shot path and the `--watch` loop, so they can't drift apart. Not combinable with `--json` (a live-refreshed raw-JSON dump isn't a coherent thing to build - errors clearly if both are passed). The delta/redraw logic itself was verified directly (calling `_build_pipeline_group()` with two synthetic consecutive polls and confirming the `+4`/`+2`/`·` rendering, plus the empty-result error path) rather than through a full `--watch` session end to end - `rich.Live`'s screen-redraw behavior depends on `console.is_terminal`, which this environment's piped/non-TTY test harness can't exercise the same way a real interactive terminal does; confirm the actual live redraw looks right in a real terminal session if this is touched again.
368
+
371
369
  `qapu trend` shows a per-variable history plus a simple linear-regression trend line (slope + direction). Three modes, picked by which options you pass:
372
370
  - **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."
373
371
  - **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 over `--last` if both are given (and `--start`/`--end` isn't).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qapu-cli"
3
- version = "0.7.2"
3
+ version = "0.7.4"
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"
@@ -15,7 +15,8 @@ from typing import Any, Optional
15
15
  import httpx
16
16
  import typer
17
17
  from rich.columns import Columns
18
- from rich.console import Console
18
+ from rich.console import Console, Group
19
+ from rich.live import Live
19
20
  from rich.panel import Panel
20
21
  from rich.table import Table
21
22
  from rich.tree import Tree
@@ -803,42 +804,16 @@ def _print_data_daily_grouped(readings: list[dict[str, Any]]) -> None:
803
804
  console.print(table)
804
805
 
805
806
 
806
- # Get a Device's Readings per Variable, Optionally Filtered by Family/Time Window
807
- @app.command("data")
808
- def device_data(
809
- device_id: str = typer.Argument(..., help="Device ID."),
810
- energy: bool = typer.Option(False, "--energy", help="Only Energy segment variables."),
811
- gsm: bool = typer.Option(False, "--gsm", help="Only GSM segment variables (signal, network, etc)."),
812
- voltage: bool = typer.Option(False, "--voltage", help="Only voltage variables (Unit == V - includes battery voltage)."),
813
- current: bool = typer.Option(False, "--current", help="Only current variables (Unit == A)."),
814
- battery: bool = typer.Option(False, "--battery", help="Only battery variables (Variable ID starting with B_)."),
815
- search: Optional[str] = typer.Option(None, "--search", help="Case-insensitive substring match on the variable ID (e.g. --search vrms for VRMS_R/S/T, or --search AE for AE_R/AE_S/AE_T/AE_L/AE_G - drop the phase/direction suffix to pull the whole family)."),
816
- last: Optional[int] = typer.Option(None, "--last", help="Show the last N buffered readings per variable instead of just the latest. Bounded by the buffer's own depth (~50 most recent packets) - not a time window."),
817
- days: Optional[int] = typer.Option(None, "--days", help="Show daily min/avg/max over the last N days per variable instead of the latest value. Ignored if --start/--end is also given (the range wins)."),
818
- start: Optional[str] = typer.Option(None, "--start", help="ISO date/datetime lower bound, inclusive (e.g. 2026-08-30 or 2026-08-30T14:00:00). Combine with --end for an explicit range - takes priority over --last/--days. Daily min/avg/max narrowed to the range if --days was also given, raw readings otherwise."),
819
- end: Optional[str] = typer.Option(None, "--end", help="ISO date/datetime upper bound, inclusive."),
820
- as_json: bool = typer.Option(False, "--json", help="Print raw JSON instead of a table."),
821
- ):
822
- """Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), or an explicit --start/--end range (GET /hermes/data/{device_id})."""
823
-
824
- # Fetch the Readings - Mode Depends on --last/--days/--start/--end,
825
- # Already Enriched with Description/Unit/Segment Regardless of Mode
826
- params: dict[str, Any] = {}
827
- if days is not None:
828
- params["days"] = days
829
- elif last is not None:
830
- params["last"] = last
831
- if start is not None:
832
- params["start"] = start
833
- if end is not None:
834
- params["end"] = end
835
- readings: list[dict[str, Any]] = client.get(f"/hermes/data/{device_id}", params=params or None)
807
+ # Apply --energy/--gsm/--voltage/--current/--battery/--search Filters to a
808
+ # Readings List - Extracted 2026-09-04 So `data --watch`'s Poll Loop Can
809
+ # Reuse the Exact Same Filter Logic as the One-Shot Path, Rather Than
810
+ # Drifting into a Second Copy.
811
+ def _apply_data_filters(readings: list[dict[str, Any]], energy: bool, gsm: bool, voltage: bool, current: bool, battery: bool, search: Optional[str]) -> list[dict[str, Any]]:
836
812
 
837
813
  # Apply Family Filters, if Any - Union (OR) Across Whichever Were Given -
838
814
  # Same Filter Logic Regardless of Mode, Since Variable_ID/Unit/Segment
839
815
  # are Present on Every Row in Every Mode
840
- filters_selected = energy or gsm or voltage or current or battery
841
- if filters_selected:
816
+ if energy or gsm or voltage or current or battery:
842
817
 
843
818
  # Filter In Place
844
819
  filtered: list[dict[str, Any]] = []
@@ -873,6 +848,112 @@ def device_data(
873
848
  needle = search.strip().lower()
874
849
  readings = [r for r in readings if needle in (r.get("Variable_ID") or "").lower()]
875
850
 
851
+ # Return the Filtered List, Sorted for Stable Output
852
+ return sorted(readings, key=lambda r: r.get("Variable_ID") or "")
853
+
854
+
855
+ # Build a Live-Watch Table for `data --watch` - VARIABLE/VALUE/UNIT/
856
+ # DEĞİŞİM (Changed-Since-Last-Poll Indicator)/TIME, Grouped Neither by
857
+ # Variable (There's Only Ever One Reading per Variable in "Latest" Mode,
858
+ # Unlike --last's Multi-Reading Groups) - Absolute Time (_format_time),
859
+ # not Relative, Since a 5-Second Poll Loop Would Show "0 dk önce" for
860
+ # Every Row Regardless of Whether it Actually Just Changed.
861
+ def _build_data_watch_table(device_id: str, readings: list[dict[str, Any]], previous: Optional[dict[str, Any]], as_of: str) -> Group:
862
+
863
+ header = f"[bold]{device_id}[/bold] - Canlı Veri [dim](güncellendi: {as_of})[/dim]"
864
+
865
+ table = Table(show_header=True, header_style="bold")
866
+ table.add_column("DEĞİŞKEN")
867
+ table.add_column("DEĞER", justify="right")
868
+ table.add_column("BİRİM")
869
+ if previous is not None:
870
+ table.add_column("DEĞİŞİM")
871
+ table.add_column("ZAMAN")
872
+
873
+ for r in readings:
874
+ variable_id = r.get("Variable_ID") or "-"
875
+ value = r.get("Value")
876
+ value_str = f"{value:g}" if isinstance(value, (int, float)) else "-"
877
+ unit = r.get("Unit") or "-"
878
+ reading_time = _format_time(r.get("Time"))
879
+
880
+ row = [variable_id, value_str, unit]
881
+ if previous is not None:
882
+ prev_value = previous.get(variable_id, {}).get("Value") if isinstance(previous.get(variable_id), dict) else None
883
+ if prev_value is None or not isinstance(value, (int, float)):
884
+ row.append("")
885
+ elif value > prev_value:
886
+ row.append(f"[green]▲ {value - prev_value:g}[/green]")
887
+ elif value < prev_value:
888
+ row.append(f"[red]▼ {prev_value - value:g}[/red]")
889
+ else:
890
+ row.append("·")
891
+ row.append(reading_time)
892
+ table.add_row(*row)
893
+
894
+ return Group(header, table)
895
+
896
+
897
+ # Get a Device's Readings per Variable, Optionally Filtered by Family/Time Window
898
+ @app.command("data")
899
+ def device_data(
900
+ device_id: str = typer.Argument(..., help="Device ID."),
901
+ energy: bool = typer.Option(False, "--energy", help="Only Energy segment variables."),
902
+ gsm: bool = typer.Option(False, "--gsm", help="Only GSM segment variables (signal, network, etc)."),
903
+ voltage: bool = typer.Option(False, "--voltage", help="Only voltage variables (Unit == V - includes battery voltage)."),
904
+ current: bool = typer.Option(False, "--current", help="Only current variables (Unit == A)."),
905
+ battery: bool = typer.Option(False, "--battery", help="Only battery variables (Variable ID starting with B_)."),
906
+ search: Optional[str] = typer.Option(None, "--search", help="Case-insensitive substring match on the variable ID (e.g. --search vrms for VRMS_R/S/T, or --search AE for AE_R/AE_S/AE_T/AE_L/AE_G - drop the phase/direction suffix to pull the whole family)."),
907
+ last: Optional[int] = typer.Option(None, "--last", help="Show the last N buffered readings per variable instead of just the latest. Bounded by the buffer's own depth (~50 most recent packets) - not a time window."),
908
+ days: Optional[int] = typer.Option(None, "--days", help="Show daily min/avg/max over the last N days per variable instead of the latest value. Ignored if --start/--end is also given (the range wins)."),
909
+ start: Optional[str] = typer.Option(None, "--start", help="ISO date/datetime lower bound, inclusive (e.g. 2026-08-30 or 2026-08-30T14:00:00). Combine with --end for an explicit range - takes priority over --last/--days. Daily min/avg/max narrowed to the range if --days was also given, raw readings otherwise."),
910
+ end: Optional[str] = typer.Option(None, "--end", help="ISO date/datetime upper bound, inclusive."),
911
+ watch: bool = typer.Option(False, "--watch", "-w", help="Live-refresh the latest-value table in place every --interval seconds (Ctrl+C to stop). Only compatible with the default latest-value mode - not combinable with --last/--days/--start/--end/--json."),
912
+ interval: float = typer.Option(5.0, "--interval", help="Seconds between refreshes in --watch mode."),
913
+ as_json: bool = typer.Option(False, "--json", help="Print raw JSON instead of a table."),
914
+ ):
915
+ """Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), an explicit --start/--end range, or a live-refreshed view (--watch) (GET /hermes/data/{device_id})."""
916
+
917
+ # --watch: Only Meaningful for the Latest-Value Mode - Refuse Clearly
918
+ # Rather than Silently Ignoring --last/--days/--start/--end/--json
919
+ if watch:
920
+ if last is not None or days is not None or start is not None or end is not None or as_json:
921
+ typer.echo("Error: --watch sadece varsayılan (son değer) modla kullanılabilir, --last/--days/--start/--end/--json ile birlikte kullanılamaz.", err=True)
922
+ raise typer.Exit(code=1)
923
+
924
+ previous: Optional[dict[str, dict[str, Any]]] = None
925
+ try:
926
+ with Live(console=console, refresh_per_second=4) as live:
927
+ while True:
928
+ readings = client.get(f"/hermes/data/{device_id}")
929
+ readings = _apply_data_filters(readings, energy, gsm, voltage, current, battery, search)
930
+ as_of = datetime.now().strftime("%H:%M:%S")
931
+ if not readings:
932
+ live.update(Group(f"[bold]{device_id}[/bold] - Canlı Veri [dim](güncellendi: {as_of})[/dim]", "\n[yellow]Filtreye uyan veri yok.[/yellow]"))
933
+ else:
934
+ live.update(_build_data_watch_table(device_id, readings, previous, as_of))
935
+ previous = {r.get("Variable_ID"): r for r in readings if r.get("Variable_ID")}
936
+ time.sleep(interval)
937
+ except KeyboardInterrupt:
938
+ return
939
+
940
+ # Fetch the Readings - Mode Depends on --last/--days/--start/--end,
941
+ # Already Enriched with Description/Unit/Segment Regardless of Mode
942
+ params: dict[str, Any] = {}
943
+ if days is not None:
944
+ params["days"] = days
945
+ elif last is not None:
946
+ params["last"] = last
947
+ if start is not None:
948
+ params["start"] = start
949
+ if end is not None:
950
+ params["end"] = end
951
+ readings: list[dict[str, Any]] = client.get(f"/hermes/data/{device_id}", params=params or None)
952
+
953
+ # Apply Family/Search Filters, if Any
954
+ filters_selected = energy or gsm or voltage or current or battery
955
+ readings = _apply_data_filters(readings, energy, gsm, voltage, current, battery, search)
956
+
876
957
  # JSON Output - Everything at Once, Never Paginated
877
958
  if as_json:
878
959
 
@@ -885,11 +966,10 @@ def device_data(
885
966
  typer.echo("Bu cihaz için henüz veri yok." if not (filters_selected or search) else "Filtreye uyan veri bulunamadı.")
886
967
  return
887
968
 
888
- # Sort for Stable Output, then Print with the Table Shape the Mode Needs -
889
- # the Response Shape (Daily/History/Value Keys) is What Actually Decides
890
- # This, not the Request Params Directly, Since --start/--end Alone (no
891
- # --days) Comes Back in the Same "History" Shape --last Uses
892
- readings = sorted(readings, key=lambda r: r.get("Variable_ID") or "")
969
+ # Print with the Table Shape the Mode Needs - the Response Shape
970
+ # (Daily/History/Value Keys) is What Actually Decides This, not the
971
+ # Request Params Directly, Since --start/--end Alone (no --days) Comes
972
+ # Back in the Same "History" Shape --last Uses
893
973
  if days is not None:
894
974
  _print_data_daily_grouped(readings)
895
975
  elif last is not None or start is not None or end is not None:
@@ -1256,32 +1336,49 @@ def device_energy(
1256
1336
  # Get One Variable's Trend on One Device - Either the Last N Raw Buffered
1257
1337
  # Readings, or Daily Min/Avg/Max Over the Last N Days (Mutually Exclusive -
1258
1338
  # --days Wins if Both are Given)
1259
- @app.command("pipeline")
1260
- def pipeline_stats(
1261
- minutes: int = typer.Option(60, "--minutes", help="Window size in minutes (default 60, max 10080/7 days)."),
1262
- as_json: bool = typer.Option(False, "--json", help="Print raw JSON instead of the summary table."),
1263
- ):
1264
- """Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats)."""
1265
- result: dict[str, Any] = client.get("/hermes/pipeline/stats", params={"minutes": minutes})
1339
+ def _window_label(minutes: int) -> str:
1266
1340
 
1267
- if as_json:
1268
- typer.echo(json.dumps(result, indent=2))
1269
- return
1341
+ # Format a Window Size as "N dk" or "N sa" (Whole Hours Only)
1342
+ if minutes < 60 or minutes % 60 != 0:
1343
+ return f"{minutes} dk"
1344
+ return f"{minutes // 60} sa"
1270
1345
 
1271
- if not result:
1272
- typer.echo("İstatistikler alınamadı.")
1273
- return
1274
1346
 
1275
- window = result.get("Window_Minutes", minutes)
1276
- window_label = f"{window} dk" if window < 60 else (f"{window // 60} sa" if window % 60 == 0 else f"{window} dk")
1347
+ # Build the Pipeline Summary as a Single Renderable (Title + Table +
1348
+ # Optional Backlog Note) - Shared by the One-Shot Print and the --watch
1349
+ # Live Loop Below, so Both Stay in Sync as the Table's Shape Evolves.
1350
+ # `previous` (the Last Poll's raw stats dict, or None) Lets --watch Show a
1351
+ # "+N since last check" Delta per Stage, Which is What Actually Makes a
1352
+ # Polling Loop Feel "Live" Rather than Just a Table that Happens to
1353
+ # Redraw - a Bare Count with No Motion Indicator Doesn't Read as
1354
+ # Streaming.
1355
+ def _build_pipeline_group(result: dict[str, Any], minutes: int, previous: Optional[dict[str, Any]] = None, as_of: Optional[str] = None) -> Group:
1277
1356
 
1278
- console.print(f"[bold]Pipeline Özeti[/bold] - son {window_label}\n")
1357
+ title = f"[bold]Pipeline Özeti[/bold] - son {_window_label(minutes)}"
1358
+ if as_of:
1359
+ title += f" [dim](güncellendi: {as_of})[/dim]"
1360
+
1361
+ if not result:
1362
+ return Group(title, "\n[red]İstatistikler alınamadı.[/red]")
1279
1363
 
1280
1364
  table = Table(show_header=True, header_style="bold")
1281
1365
  table.add_column("AŞAMA")
1282
1366
  table.add_column("SAYI", justify="right")
1367
+ if previous is not None:
1368
+ table.add_column("DEĞİŞİM", justify="right")
1283
1369
  table.add_column("DETAY")
1284
1370
 
1371
+ def delta_str(key: str, current: int) -> str:
1372
+ if previous is None:
1373
+ return ""
1374
+ prev_value = previous.get(key, 0) or 0
1375
+ diff = current - prev_value
1376
+ if diff > 0:
1377
+ return f"[green]+{diff}[/green]"
1378
+ if diff < 0:
1379
+ return f"[red]{diff}[/red]"
1380
+ return "·"
1381
+
1285
1382
  ingest_total = result.get("Ingest_Total", 0)
1286
1383
  ingest_valid = result.get("Ingest_Valid", 0)
1287
1384
  ingest_invalid = result.get("Ingest_Invalid", 0)
@@ -1289,18 +1386,65 @@ def pipeline_stats(
1289
1386
  rule_events = result.get("Rule_Events", 0)
1290
1387
  blocks_mined = result.get("Blockchain_Mined", 0)
1291
1388
 
1292
- table.add_row("Ingest (hardware)", str(ingest_total), f"{ingest_valid} geçerli, {ingest_invalid} geçersiz")
1293
- table.add_row("Data İşlendi (data)", str(data_processed), "kalibrasyon + sentez tamamlanan paket")
1294
- table.add_row("Kural Olayları (rule)", str(rule_events), "tetiklenen/sıfırlanan olay (device_timeline)")
1295
- table.add_row("Blockchain Kazıldı", str(blocks_mined), "yeni blok")
1389
+ rows = [
1390
+ ("Ingest (hardware)", ingest_total, "Ingest_Total", f"{ingest_valid} geçerli, {ingest_invalid} geçersiz"),
1391
+ ("Data İşlendi (data)", data_processed, "Data_Processed", "kalibrasyon + sentez tamamlanan paket"),
1392
+ ("Kural Olayları (rule)", rule_events, "Rule_Events", "tetiklenen/sıfırlanan olay (device_timeline)"),
1393
+ ("Blockchain Kazıldı", blocks_mined, "Blockchain_Mined", "yeni blok"),
1394
+ ]
1395
+ for label, value, key, detail in rows:
1396
+ row = [label, str(value)]
1397
+ if previous is not None:
1398
+ row.append(delta_str(key, value))
1399
+ row.append(detail)
1400
+ table.add_row(*row)
1296
1401
 
1297
- console.print(table)
1402
+ parts: list[Any] = [title, table]
1298
1403
 
1299
1404
  # Simple Backlog Hint - not an Alert, Just a Note if the Numbers Look
1300
1405
  # Off (data_processed Should Track ingest_valid Closely Since data
1301
1406
  # Consumes qapu.raw 1:1)
1302
1407
  if ingest_valid > 0 and data_processed < ingest_valid * 0.8:
1303
- console.print(f"\n[yellow]Not: Data işlenen paket sayısı ({data_processed}), geçerli ingest sayısından ({ingest_valid}) belirgin şekilde düşük - olası birikme/gecikme, kontrol edilebilir.[/yellow]")
1408
+ parts.append(f"[yellow]Not: Data işlenen paket sayısı ({data_processed}), geçerli ingest sayısından ({ingest_valid}) belirgin şekilde düşük - olası birikme/gecikme, kontrol edilebilir.[/yellow]")
1409
+
1410
+ return Group(*parts)
1411
+
1412
+
1413
+ @app.command("pipeline")
1414
+ def pipeline_stats(
1415
+ minutes: int = typer.Option(60, "--minutes", help="Window size in minutes (default 60, max 10080/7 days)."),
1416
+ watch: bool = typer.Option(False, "--watch", "-w", help="Live-refresh the summary in place every --interval seconds (Ctrl+C to stop), instead of printing once."),
1417
+ interval: float = typer.Option(5.0, "--interval", help="Seconds between refreshes in --watch mode."),
1418
+ as_json: bool = typer.Option(False, "--json", help="Print raw JSON instead of the summary table. Not combinable with --watch."),
1419
+ ):
1420
+ """Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats)."""
1421
+
1422
+ if as_json and watch:
1423
+ typer.echo("Error: --json ve --watch birlikte kullanılamaz.", err=True)
1424
+ raise typer.Exit(code=1)
1425
+
1426
+ # --watch: Poll on a Live-Refreshed Loop Until Ctrl+C
1427
+ if watch:
1428
+ previous: Optional[dict[str, Any]] = None
1429
+ try:
1430
+ with Live(console=console, refresh_per_second=4) as live:
1431
+ while True:
1432
+ result: dict[str, Any] = client.get("/hermes/pipeline/stats", params={"minutes": minutes})
1433
+ as_of = datetime.now().strftime("%H:%M:%S")
1434
+ live.update(_build_pipeline_group(result, minutes, previous=previous, as_of=as_of))
1435
+ previous = result or previous
1436
+ time.sleep(interval)
1437
+ except KeyboardInterrupt:
1438
+ return
1439
+
1440
+ # One-Shot Fetch
1441
+ result: dict[str, Any] = client.get("/hermes/pipeline/stats", params={"minutes": minutes})
1442
+
1443
+ if as_json:
1444
+ typer.echo(json.dumps(result, indent=2))
1445
+ return
1446
+
1447
+ console.print(_build_pipeline_group(result, minutes))
1304
1448
 
1305
1449
 
1306
1450
  @app.command("trend")
@@ -1,3 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: qapu-cli
3
+ Version: 0.7.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
+ Author: OVOO Technology
6
+ Classifier: Programming Language :: Python :: 3
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer<1.0,>=0.12
10
+ Requires-Dist: httpx<1.0,>=0.27
11
+ Requires-Dist: rich<16.0,>=13.0
12
+
1
13
  # Qapu CLI
2
14
 
3
15
  A thin command-line client for the Qapu API (`api.ovoo.com.tr`), built for the Hermes agent (runs outside this Swarm, in a separate datacenter, and only ever talks to Qapu through this public API) - but usable by anything else that needs to script against Qapu from outside the private network.
@@ -44,10 +56,10 @@ Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`
44
56
  |---|---|
45
57
  | `qapu ct-check` | Diagnose CT (current transformer) wiring per phase - reversed polarity vs. wrong phase order - from live PF_R/S/T/AE_R/S/T (GET /hermes/data/{device_id}). |
46
58
  | `qapu current` | Current synthesis parameters, grouped (RMS, imbalance, THD, crest factor, fundamental component) (GET /hermes/data/{device_id}). |
47
- | `qapu data` | Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), or an explicit --start/--end range (GET /hermes/data/{device_id}). |
59
+ | `qapu data` | Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), an explicit --start/--end range, or a live-refreshed view (--watch) (GET /hermes/data/{device_id}). |
48
60
  | `qapu energy` | One day's active/reactive energy breakdown per phase (GET /hermes/energy/{device_id}). |
49
61
  | `qapu health` | Check CLI/server version, connectivity, and server status (GET /health, no auth required). |
50
- | `qapu pipeline` | Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats). |
62
+ | `qapu pipeline` | Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats). `--watch` live-refreshes in place. |
51
63
  | `qapu power` | Power synthesis parameters, grouped (active/reactive/apparent, imbalance, power factor) (GET /hermes/data/{device_id}). |
52
64
  | `qapu timeline` | Get one device's timeline events (GET /hermes/timeline/{device_id}). |
53
65
  | `qapu trend` | Trend for one variable on one device: recent readings, daily min/avg/max, or an explicit --start/--end range (GET /hermes/trend/{device_id}/{variable_id}). |
@@ -307,6 +319,14 @@ qapu data <device_id> --start 2026-08-30 --end 2026-08-31 --days 5 # daily mi
307
319
 
308
320
  **Refined again same day, per three more rounds of direct feedback**: (1) `--last`'s TIME column showed a coarse relative bucket ("1 gün önce") for every one of the N readings - useless once several readings from the same day all collapse to the same bucket. Added a new `_format_time()` helper (`tools/cli/qapu_cli/main.py`, next to the existing `_relative_time()`) that renders the actual data timestamp as `YYYY-MM-DD HH:MM:SS` instead - used only where each row needs its own real time (this per-reading table); `_relative_time()` itself (SIM last-connection, timeline, etc.) is untouched, still relative, since "how long ago" is the right framing there. (2) The per-variable group tables were initially borderless (`box=None`) for a lighter look - reverted to the same bordered `Table()` style every other CLI table already uses ("görsel kısmı güzelleştir tablolu yap"), since the borderless version read as less like a real table, not more. `--json` output is untouched either way ("--json diyince ai kendine göre okur onu" - raw JSON is for machine consumption, formatting choices don't need to accommodate it). (3) The `VARIABLE_ID — Description (Unit)` heading was originally a plain `console.print()` line sitting above each group's table, not visually part of it - moved onto the `Table` itself via Rich's `title=`/`title_justify="left"`/`title_style="bold"` params, so it now renders attached to the table's own border ("kısmını da tabloya dahil edelim tablo yapısı ile görünsün"). Live-tested against real prod data: `qapu data <id> --search VRMS --last 5` now shows 5 grouped, bordered, titled tables with real per-reading timestamps (e.g. `2026-09-01 03:12:14`).
309
321
 
322
+ **`qapu data --watch`/`-w` (added 2026-09-04)** - the same live-refresh idea `qapu pipeline --watch` uses (see that command's own entry further down for the underlying pattern), applied to a single device's latest readings instead of the fleet-wide pipeline summary, per a same-day follow-up ("aynı şekilde data izlerken de bir canlı uç açabilirmiyiz"):
323
+ ```bash
324
+ qapu data <device_id> --watch # every variable, live
325
+ qapu data <device_id> --watch --search VRMS # combine with family/search filters, same as normal mode
326
+ qapu data <device_id> --watch --interval 2 # poll every 2s instead of the default 5s
327
+ ```
328
+ Only valid for the default latest-value mode - refuses clearly if combined with `--last`/`--days`/`--start`/`--end`/`--json`. Filtering logic (`--energy`/`--gsm`/`--voltage`/`--current`/`--battery`/`--search`) was extracted into a shared `_apply_data_filters()` so the watch loop and the normal path can't drift apart. Each poll after the first shows a `DEĞİŞİM` column - green `▲ <diff>` if a variable rose since the last poll, red `▼ <diff>` if it fell, `·` if unchanged - and `ZAMAN` shows the absolute reading timestamp (`_format_time()`, not `_relative_time()` - a 5-second poll loop would show "0 dk önce" for every row regardless of whether it just changed, same reasoning already applied to `--last`'s per-reading table above). Verified the same way as `pipeline --watch`'s delta logic: `_build_data_watch_table()` called directly with two synthetic consecutive polls (one variable rising, one falling, one flat), confirmed correct `▲`/`▼`/`·` rendering - `rich.Live`'s actual screen-redraw behavior wasn't separately re-verified end to end here either, for the same non-TTY sandbox reason noted under `pipeline --watch`.
329
+
310
330
  ```bash
311
331
  qapu trend <device_id> <variable_id> # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
312
332
  qapu trend <device_id> <variable_id> --last 50 # last N raw readings instead of the default 20
@@ -356,6 +376,8 @@ qapu pipeline --json
356
376
  ```
357
377
  Not device-scoped - a fleet-wide summary across every service in the `ingest -> stream -> calibration -> raw-writer -> synthesis -> ... -> rule` pipeline. Deliberately reads from the real tables each stage actually writes to (`raw_data.stream_time`/`valid_pack` for `hardware`'s ingest, `streams.stream_time` for `data`'s calibration+synthesis completion, `device_timeline.create_time` for `rule`'s observable output, `blockchain.create_time` for mined blocks) rather than any service's own `/health` endpoint - those are point-in-time snapshots (uptime, CPU, last-message-age), not a windowed throughput count. New `common/qapu_common/domain/pipeline.py` (`Pipeline_Stats.get_stats(minutes)`, four plain `SELECT count(*) ... WHERE <time column> >= now() - interval` queries, no caching - these are meant to be live) + `GET /hermes/pipeline/stats` (`services/api/src/routers/hermes.py`, `minutes` query param, 1 to 10080/7 days). **`Rule_Events` deliberately does NOT count rule evaluations** - `rule` evaluates every single stream message 1:1, the same count `Data_Processed` already reports, so repeating it would be redundant; it counts `device_timeline` rows instead, rule's actual observable output (a trigger/reset transition), not every evaluation pass. The CLI prints a bordered summary table plus a soft backlog hint (yellow note, not an error) if `Data_Processed` looks meaningfully behind `Ingest_Valid` (`<80%`) - a sign of consumer lag worth checking, not itself proof of one. Live-tested against real prod DB: `--minutes 60` correctly returned all zeros (no test traffic in the preceding hour at that point in the session), `--minutes 1440` correctly returned real 24h counts, and a fresh real packet sent immediately before a `--minutes 5` check showed up as exactly `+1` across Ingest/Data_Processed/Blockchain_Mined (Rule_Events stayed `0`, correctly - that packet's only variable, `PCB_T`, didn't cross any rule threshold).
358
378
 
379
+ **`--watch`/`-w` added the same day**, per direct follow-up request ("pipe line canlı takip edilebilecek bir tool yapabilirmiyiz") - polls the same endpoint every `--interval` seconds (default `5.0`) and redraws the summary in place via Rich's `Live`, instead of printing once and exiting; `Ctrl+C` stops cleanly. Each poll after the first also shows a `DEĞİŞİM` column - green `+N` if a stage's count grew since the last poll, red `-N` if it somehow shrank (shouldn't happen for these monotonic counters within a fixed window, but rendered anyway rather than assumed impossible), `·` if unchanged - a bare redrawn count doesn't read as "live" the way a visible delta does. Refactored the table-building logic into a shared `_build_pipeline_group()` (title + table + optional backlog note, returned as one `rich.console.Group`) used by both the one-shot path and the `--watch` loop, so they can't drift apart. Not combinable with `--json` (a live-refreshed raw-JSON dump isn't a coherent thing to build - errors clearly if both are passed). The delta/redraw logic itself was verified directly (calling `_build_pipeline_group()` with two synthetic consecutive polls and confirming the `+4`/`+2`/`·` rendering, plus the empty-result error path) rather than through a full `--watch` session end to end - `rich.Live`'s screen-redraw behavior depends on `console.is_terminal`, which this environment's piped/non-TTY test harness can't exercise the same way a real interactive terminal does; confirm the actual live redraw looks right in a real terminal session if this is touched again.
380
+
359
381
  `qapu trend` shows a per-variable history plus a simple linear-regression trend line (slope + direction). Three modes, picked by which options you pass:
360
382
  - **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."
361
383
  - **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 over `--last` if both are given (and `--start`/`--end` isn't).
File without changes
File without changes
File without changes