Metadata-Version: 2.4
Name: qapu-cli
Version: 0.6.5
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.
Author: OVOO Technology
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: typer<1.0,>=0.12
Requires-Dist: httpx<1.0,>=0.27
Requires-Dist: rich<16.0,>=13.0

# Qapu CLI

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.

**Status: early development.** Gated by a temporary shared-secret header, not real auth yet - see "Auth (current placeholder)" below before using this against production.

## Why a separate `tools/` project, not a `services/`

This isn't a deployed backend service - it's a distributable client tool, installed wherever Hermes (or anyone else) runs. Kept in the same monorepo (per `CLAUDE.md`'s ADR-0001 - one repo, low context-switching for a 2-person team) rather than its own repo, since it's small and needs to stay in sync with the API it calls.

## Install

**Anyone with GitHub access to this (private) repo - no local clone needed**, `pip` installs straight from the `tools/cli` subdirectory over git:

```bash
pip install "git+ssh://git@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"
```

(needs an SSH key already authorized on your GitHub account for this repo - the usual case for anyone on the team. No SSH key set up? Use an HTTPS Personal Access Token instead: `pip install "git+https://<PAT>@github.com/ovoo-tech/qapu.git#subdirectory=tools/cli"`.)

**Working on the CLI itself** (this repo already checked out) - editable install so local edits take effect immediately:

```bash
cd tools/cli
pip install -e .
```

Either way installs a `qapu` command (see `pyproject.toml`'s `[project.scripts]`). This package has no dependency on the rest of the monorepo (`qapu_common` etc.) - it only ever talks to Qapu over HTTP, never imports it directly - which is exactly what makes the git-subdirectory install above work without cloning anything else.

## Configuration

| Env var | Purpose |
|---|---|
| `QAPU_API_URL` | Base URL. Defaults to `https://api.ovoo.com.tr`. Point at `http://localhost:8000` or an internal IP for local/dev testing. |
| `QAPU_HERMES_KEY` | Shared-secret value for the `X-Hermes-Key` header - see "Auth" below. Required for every command except `qapu health`. |

## Commands

```bash
qapu --version                       # print the installed qapu-cli version and exit
qapu health                          # GET /health - no auth, connectivity + CLI/server version + PyPI update check
qapu health --json                   # same data, raw JSON
qapu device list                     # GET /hermes/devices - every device, bulk, as a table
qapu device list --json              # same data, raw JSON
qapu device list --status online     # only devices whose Update_Time moved in the last 30 min (see ONLINE_THRESHOLD_MINUTES in main.py - there's no real online/offline field, this is a heuristic)
qapu device list --status offline
qapu device list --model B107AA_R5   # case-insensitive substring match on Hardware.Model.Name
qapu device list --limit 100
qapu device get <device_id>          # GET /hermes/devices/{device_id} - one device, human-readable summary
qapu device get <device_id> --json   # same data, raw JSON
qapu variable list                   # GET /hermes/variables - full variable catalog, as a table
qapu variable list --json            # same data, raw JSON
qapu variable list --segment energy  # filter by segment name (server-side)
qapu variable list --search vrms     # substring match on ID or description (client-side)
qapu variable get <variable_id>      # GET /hermes/variables/{variable_id} - one variable, human-readable summary
qapu variable get <variable_id> --json
qapu data <device_id>                # GET /hermes/data/{device_id} - latest value + timestamp per variable
qapu data <device_id> --json
qapu data <device_id> --energy       # only Energy segment variables
qapu data <device_id> --gsm          # only GSM segment variables
qapu data <device_id> --voltage      # only voltage variables (Unit == V, includes battery voltage)
qapu data <device_id> --current      # only current variables (Unit == A)
qapu data <device_id> --battery      # only battery variables (Variable ID starting with B_)
qapu data <device_id> --search vrms  # substring match on variable ID, e.g. VRMS_R/S/T only
qapu data <device_id> --last 10      # last N buffered readings per variable, instead of just the latest
qapu data <device_id> --days 2       # daily min/avg/max over the last N days per variable, instead of the latest value
qapu data <device_id> --days 2 --energy --search AE   # combine freely - family/search filters apply on top of either time-window mode
qapu data <device_id> --start 2026-08-30 --end 2026-08-31             # raw readings in an explicit range, instead of --last
qapu data <device_id> --start 2026-08-30 --end 2026-08-31 --days 5    # daily min/avg/max narrowed to that range, instead of --days N's "last N days"
```

`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`, or `AE_R`/`AE_S`/`AE_T`/`AE_TOT` by dropping the phase/direction suffix entirely. 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.

**`--last`/`--days` added 2026-09-02**, per the user's request for a time-window filter on `data` (previously only `trend`, single-variable, had one). Same two modes `trend` already offers, now across every variable on the device in one call: `--last N` shows the last N buffered readings per variable (bounded by the buffer's own ~50-entry depth, not a real time window); `--days N` shows daily min/avg/max over the last N days per variable (takes priority over `--last` if both are given). Backed by a new `Measurement_Cache.get_recent_readings()` (`common/qapu_common/domain/measurement_cache.py`) - reads the whole buffer once and keeps the last N entries per variable, same single-Redis-round-trip pattern `get_latest_readings()` already used, rather than one Redis call per variable. The family/search filters apply identically regardless of mode, since `Variable_ID`/`Unit`/`Segment` are present on every row either way - confirmed live: `--days 2 --energy --search AE` correctly returns AE_R/AE_S/AE_T/AE_TOT's daily min/avg/max together, the exact "family block" use case that prompted `--search`'s AE-without-a-phase-suffix example above.

**`--last`/`--days` output rewritten as grouped-by-variable sections, not one flat table (2026-09-02)**, per direct user feedback that the original single table (one row per (variable, reading), Variable ID/Description/Unit repeated on every row) got hard to read once several variables were interleaved ("gruplayarak verelim böyle karışık oluyor"). Both modes now print one bold `VARIABLE_ID — Description (Unit)` heading per variable, followed by a small table for just that variable, blank line between groups - no columns repeated. The default (no `--last`/`--days`, one row per variable) table is unchanged, this only affects the two time-window modes.

**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`).

```bash
qapu trend <device_id> <variable_id>            # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
qapu trend <device_id> <variable_id> --last 50  # last N raw readings instead of the default 20
qapu trend <device_id> <variable_id> --days 2   # daily min/avg/max over the last 2 days, instead of raw readings
qapu trend <device_id> <variable_id> --days 30  # ...or the last month
qapu trend <device_id> <variable_id> --start 2026-08-30T14:00:00 --end 2026-08-30T18:00:00  # raw readings in an explicit window (e.g. around an incident)
qapu trend <device_id> <variable_id> --start 2026-08-01 --end 2026-08-31 --days 5           # daily min/avg/max narrowed to that range
qapu trend <device_id> <variable_id> --json
```

`qapu trend` shows a per-variable history plus a simple linear-regression trend line (slope + direction). Three modes, picked by which options you pass:
- **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."
- **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).
- **Explicit range** (`--start`/`--end`, added 2026-09-02): an ISO date (`2026-08-30`) or datetime (`2026-08-30T14:00:00`), inclusive on both ends - takes priority over `--last`/`--days`. Daily min/avg/max narrowed to the range if `--days` was *also* given, raw readings narrowed to the range otherwise (e.g. "what did this variable do between 14:00 and 18:00 the day of the incident"). `/hermes/trend/{device_id}/{variable_id}` already returns the full raw series and full daily history unconditionally with no server-side slicing at all - `--last`/`--days`/`--start`/`--end` are all applied client-side in the CLI over that same payload, so this needed zero backend changes.

**Real bug found and fixed while adding `--start`/`--end` to `trend`**: its raw-readings table used `_relative_time()` for the TIME column, the same coarse "1 gün önce" bucket already fixed on `data`'s grouped tables for the identical reason - useless once several readings from the same day (or the same incident window) are shown together, and actively wrong for the whole point of a `--start`/`--end` window (seeing exact times around an event). Switched to `_format_time()` (absolute `YYYY-MM-DD HH:MM:SS`), matching `data`'s fix - `trend`'s own daily-mode DATE column was already absolute, only the raw-readings TIME column had this. `_relative_time()` itself is untouched everywhere else it's used (SIM last-connection, timeline, etc.).

**A second, more serious bug found and fixed the same day, caught live by the user**: `trend --json` printed the API's completely unfiltered response outright (`if as_json: typer.echo(json.dumps(t, indent=2)); return`, before any of `--last`/`--days`/`--start`/`--end` were applied) - meaning `--json` always showed everything the API returned regardless of which mode was requested. This read as `--start`/`--end` being silently broken specifically under `--json` ("dönen serideki 12 verinin 12'si de istenen aralığın dışındaydı... --json ile --start/--end fiilen ignore ediliyor gibi") - the human-readable table view was already filtering correctly the whole time, only `--json` bypassed it via an early return. Fixed by moving the `--json` output to *after* each mode's filtering, so it now reflects exactly what was selected: raw/range mode narrows `Series` and blanks `Daily`, daily mode narrows `Daily` and blanks `Series` - never a mix of "the filtered view you asked for" and "everything else unfiltered," which would have been confusing on its own even after the primary bug was fixed. Live-tested against real prod data: `trend <id> VRMS_A --start 2026-09-01T02:00:00 --end 2026-09-01T02:30:00 --json` now correctly returns only the 5 real readings in that window (`Daily: {}`); the same range with `--days 5` correctly returns only `2026-08-30`/`2026-08-31` in `Daily` (`Series: []`); `--last 3 --json` unaffected (still the last 3 readings). 492 tests still passing (no domain-layer changes - this was CLI-only, `/hermes/trend/...` itself was never the problem, it already returns everything unconditionally by design).

**`qapu data`'s `--start`/`--end` (added 2026-09-02)** works differently under the hood since `/hermes/data/{device_id}` (unlike `/trend`) already does server-side slicing for `--last`/`--days`, to avoid shipping every variable's entire history on every call - so the range filter had to move server-side too. `Hermes_Get_Data` (`services/api/src/routers/hermes.py`) gained `start`/`end` query params and two new module-level helpers, `_parse_naive_datetime()`/`_time_in_range()` - every timestamp compared is treated as naive (timezone stripped) since the whole project already runs on one fixed local timezone (+03:00, Europe/Istanbul), which sidesteps aware-vs-naive comparison errors without needing to guess/attach an offset to a bare `2026-08-30` the caller might pass. Daily mode filters `get_daily_history()`'s day-keys (`"YYYY-MM-DD"` strings, lexicographic comparison is correct) to the range instead of `[-days:]`-slicing; raw mode reads the whole buffer (`get_recent_readings(last=1_000_000)` - the buffer itself never exceeds its own ~50-entry depth regardless, so this is really "read everything there is") and filters by timestamp instead of just keeping the last N. Live-tested against real prod data: `data <id> --search VRMS_A --start 2026-09-01T02:00:00 --end 2026-09-01T02:30:00` correctly returned exactly the 5 readings in that window; combined with `--days 5`, the daily aggregates correctly narrowed to just `2026-08-30`/`2026-08-31`; an out-of-range window (`2020-01-01`) correctly returned nothing. 492 tests still passing (no domain-layer changes - `Measurement_Cache` itself wasn't touched, the range logic lives entirely in the Hermes router and the CLI).

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.

```bash
qapu group list                                     # GET /hermes/groups - every device group, as a table
qapu group list --json
qapu group list --active                            # only active groups (--inactive for the opposite)
qapu group get <group_id>                           # GET /hermes/groups/{group_id} - one group, human-readable summary
qapu group get <group_id> --json
qapu group devices <group_id>                       # GET /hermes/groups/{group_id}/devices - device IDs assigned to a group
qapu group devices <group_id> --json
qapu group add "KOSKI - Parsana" --description "..." --tags KOSKI,Parsana   # POST /hermes/groups
qapu group add "KOSKI - Parsana" --project-id 1      # optionally scope to a project
qapu group update <group_id> --name "..." --description "..." --tags a,b --active   # PUT /hermes/groups/{group_id} - only passed fields change
qapu group assign <group_id> <device_id> --assigned-by <user_id>     # POST /hermes/groups/{group_id}/devices/{device_id}
qapu group unassign <group_id> <device_id>           # DELETE /hermes/groups/{group_id}/devices/{device_id}
```

Groups are **flat** - there's no parent/child relationship in the underlying `groups` table (deliberately, see `common/qapu_common/domain/group.py`'s header and CLAUDE.md's 2026-09-01 entry). A "sub-group" like `"KOSKI - Parsana"` is just another group row, related to `"KOSKI"` by naming convention only, not a real database link. `group assign` requires `--assigned-by <user_id>` explicitly - unlike every read-only command above, this writes to `group_assignments.assigned_by` (a real `users.id`, `NOT NULL`), and the CLI has no login flow yet to infer who's calling (see "Auth" below), so the caller supplies it.

```bash
qapu manufacturer list [--json] [--limit N]        # GET /hermes/manufacturers - grouped by ID thousand (3xxx = "Electric Equipment", etc.), ID-ordered
qapu manufacturer get <manufacturer_id> [--json]   # GET /hermes/manufacturers/{id}
qapu manufacturer add "Name" --category <electronics|modem|electric-equipment|transformator|pump> [--description]   # POST /hermes/manufacturers
qapu manufacturer update <manufacturer_id> [--name] [--description]   # PUT /hermes/manufacturers/{id} - only passed fields change
qapu manufacturer delete <manufacturer_id>                            # DELETE /hermes/manufacturers/{id}
qapu model list [--json] [--limit N]                # GET /hermes/models - same thousand-grouping as manufacturer, plus a MANUFACTURER column
qapu model get <model_id> [--json]                  # GET /hermes/models/{id}
qapu model add "Name" --manufacturer-id <id> [--description]   # POST /hermes/models - manufacturer also determines its category
qapu model update <model_id> [--name] [--description] [--manufacturer-id <id>]   # PUT /hermes/models/{id} - only passed fields change; changing manufacturer relocates the ID if the category differs (see below)
qapu model delete <model_id>                                    # DELETE /hermes/models/{id}
qapu modem list [--manufacturer-id] [--model-id] [--status-id] [--json] [--limit N]   # GET /hermes/modems
qapu modem get <imei> [--json]                      # GET /hermes/modems/{imei}
qapu sim list [--operator-id] [--active/--inactive] [--online/--offline] [--json] [--limit N]   # GET /hermes/sims
qapu sim get <iccid> [--json]                       # GET /hermes/sims/{iccid}
qapu firmware list [--active/--inactive] [--channel] [--json] [--limit N]   # GET /hermes/firmware
qapu firmware get <version> [--json]                # GET /hermes/firmware/{version}
qapu status list [--json] [--limit N]               # GET /hermes/statuses
qapu status get <status_id> [--json]                # GET /hermes/statuses/{id}
qapu status add "Name" [--id N] [--description]     # POST /hermes/statuses - pass --id to land in a specific entity-kind range (1xx=device, 2xx=..., see `status list`)
qapu status update <status_id> [--name] [--description]   # PUT /hermes/statuses/{id} - only passed fields change
qapu status delete <status_id>                             # DELETE /hermes/statuses/{id}
qapu timeline <device_id> [--unread] [--page N] [--size N] [--json]   # GET /hermes/timeline/{device_id}

qapu blockchain list <device_id> [--json]           # GET /hermes/blockchain/{device_id} - every mined block, genesis to latest
qapu blockchain get <device_id> <index>             # GET /hermes/blockchain/{device_id}/{index} - one block's full payload
qapu blockchain validate <device_id> [--json]       # GET /hermes/blockchain/{device_id}/validate - length, secure/tampered status, time range

qapu calibration list <device_id> [--json]          # GET /hermes/calibration/{device_id} - Gain/Offset per variable
qapu calibration get <calibration_id> [--json]      # GET /hermes/calibration/detail/{calibration_id}
qapu calibration add <device_id> <variable_id> --gain <g> --offset <o> [--description]   # POST /hermes/calibration
qapu calibration update <calibration_id> [--gain] [--offset] [--description]             # PUT /hermes/calibration/{calibration_id} - only passed fields change
qapu calibration delete <calibration_id>                                                  # DELETE /hermes/calibration/{calibration_id}

qapu crop-type list [--json] [--limit N]            # GET /hermes/crop_types
qapu crop-type get <id> [--json]                    # GET /hermes/crop_types/{id}
qapu crop-type add "Wheat" --description "..."      # POST /hermes/crop_types - a new kind is just a new row
qapu crop-type update <id> [--name] [--description] # PUT /hermes/crop_types/{id} - only passed fields change
qapu crop-type delete <id>                           # DELETE /hermes/crop_types/{id}

qapu irrigation-type list [--json] [--limit N]            # GET /hermes/irrigation_types
qapu irrigation-type get <id> [--json]                    # GET /hermes/irrigation_types/{id}
qapu irrigation-type add "Drip" --description "..."       # POST /hermes/irrigation_types - a new kind is just a new row
qapu irrigation-type update <id> [--name] [--description] # PUT /hermes/irrigation_types/{id} - only passed fields change
qapu irrigation-type delete <id>                           # DELETE /hermes/irrigation_types/{id}

qapu setting get <device_id> [--json]               # GET /hermes/setting/{device_id} - thresholds, register, electric box
qapu setting update <device_id> [--stop] [--publish] [--ct-ratio] [--auto-start-delay] [--temp-min] [--temp-max] [--voltage-min] [--voltage-max] [--current-max] [--frequency-min] [--frequency-max] [--voltage-imbalance-max] [--current-imbalance-max] [--pressure-min] [--pressure-max] [--pressure-slope-min] [--pressure-slope-max]

qapu stream list <device_id> [--page N] [--size N] [--json]   # GET /hermes/stream/{device_id} - ingested packet history, most recent first
qapu stream get <stream_id>                                     # GET /hermes/stream/detail/{stream_id}

qapu synthesis list [--device-id] [--variable-id] [--active/--inactive] [--json]   # GET /hermes/synthesis - derived-metric calculation rules
qapu synthesis get <rule_id> [--json]                                             # GET /hermes/synthesis/{rule_id}
qapu synthesis add <variable_id> --required-variables A,B,C --calculation '{"formula": "A + B"}' [--device-id] [--conditions] [--min-value] [--max-value] [--priority] [--active/--inactive] [--description]  # POST /hermes/synthesis
qapu synthesis update <rule_id> [--variable-id] [--required-variables] [--calculation] [--device-id] [--conditions] [--min-value] [--max-value] [--priority] [--active/--inactive] [--description]  # PUT /hermes/synthesis/{rule_id}
qapu synthesis delete <rule_id>                                                    # DELETE /hermes/synthesis/{rule_id}
qapu user-role list [--json] [--limit N]                # GET /hermes/user_roles - e.g. admin/farmer/technician, NOT the User class itself (PII, deliberately excluded)
qapu user-role get <role_id> [--json]                    # GET /hermes/user_roles/{id}
qapu user-role add <id> "Name" [--description]           # POST /hermes/user_roles - ID is REQUIRED, this catalog's ID is NOT auto-increment
qapu user-role update <role_id> [--name] [--description] # PUT /hermes/user_roles/{id} - only passed fields change
qapu user-role delete <role_id>                           # DELETE /hermes/user_roles/{id}

qapu irrigation list <device_id> [--json]       # GET /hermes/irrigation/{device_id} - event history (start/end/duration)
qapu irrigation abstract <device_id> [--json]   # GET /hermes/irrigation/{device_id}/abstract - last irrigation + 24h/today/30d/month totals

qapu project list [--json] [--active/--inactive] [--limit N]  # GET /hermes/projects
qapu project get <project_id> [--json]                        # GET /hermes/projects/{project_id}
qapu project devices <project_id> [--json] [--limit N]        # GET /hermes/projects/{project_id}/devices - full device detail, not just IDs
qapu project add "Name" [--description]                       # POST /hermes/projects
qapu project update <project_id> [--name] [--description] [--active/--inactive]  # PUT /hermes/projects/{project_id}
qapu project delete <project_id>                               # DELETE /hermes/projects/{project_id}
```

**`synthesis` extended to full CRUD (2026-09-03)**, per direct user correction after the read-only version shipped the day before ("qapu synthesis için tam crud yapmamışsın sadece list var"). The 2026-09-02 read-only version was a faithful reflection of the domain class at the time - `Synthesized_Metrics` genuinely had no `add()`/`update()`/`delete()`, only `get_list()`. Added `detail()`/`add()`/`update()`/`delete()` to `common/qapu_common/domain/synthesized_metrics.py` (following the same partial-update/cache-invalidation pattern every other domain class in this session uses), then wired all four into `services/api/src/routers/hermes.py` and the CLI. `--required-variables`/`--conditions` take comma-separated/JSON-string input respectively (`Required_Variables` is a plain list, `Conditions`/`Calculation` are JSON columns) - `--calculation`/`--conditions` are validated as JSON client-side before the request goes out, with a clear error instead of a confusing 400 from the API. New unit tests in `common/tests/test_synthesized_metrics.py` (detail/add/update/delete, 10 new cases) - 502 tests passing.

**Two real production findings surfaced while live-testing this, neither a bug in the new code, both since resolved (see CLAUDE.md's 2026-09-03 entries for the full incident/recovery detail)**:
1. **`synthesis_variable_rules` turned out to be completely empty in production at the time** - `qapu synthesis list` had been showing 12 rules, but that was a stale Redis cache with nothing backing it in the actual table. Force-refreshing that cache to verify the DB state (`Synthesized_Metrics().get_list(update=True)`) deleted the cache key without anything to refill it from, since the DB read came back empty - and since `synthesize_measurements()` (`common/qapu_common/domain/measurement.py`, called on every real device packet by `services/data`) reads through that same cache, this briefly took real, actively-used synthesis logic out of production, not just cleared a harmless stale copy. **Recovered same day**: the user pulled a dump of the equivalent table from the legacy monolith's own (separate, pre-migration) database via pgAdmin and handed over 9 real rule definitions (weather-related ones deliberately excluded), re-inserted via `Synthesized_Metrics().add()` - formulas/required-variables/conditions/min-max/description preserved verbatim. A real ordering bug surfaced in the restored data itself (not caused by the restore) - `PF_IMB` depends on `PF_AVG` but was ID-ordered before it in the legacy dump, so it was silently skipped every evaluation; fixed by re-inserting `PF_AVG` before `PF_IMB` so ID order matches the dependency. All 9 rules verified correct via a real `calculate_synthesized_metrics()` run.
2. **A "global" rule (`Device_ID="0"`, applied to every device) could not be added at all** - `synthesis_variable_rules.device_id` is a foreign key into `devices.id`, and there was no `id='0'` placeholder row in `devices` (unlike `projects`/`statuses`, which do have a real `id=0` "Unknown" row). **Fixed same day, per explicit user request**: added `devices.id='0'` ("Unknown Device", `Status_ID`/`Project_ID` both `0`, both already real "Unknown" rows) via `Device().add()`; `Device.list()` (`common/qapu_common/domain/device.py`) updated to exclude `id="0"` (same exclusion `Project.list()` already applies to its own Unknown row) so this placeholder never appears in `qapu device list`/`GET /hermes/devices` as if it were a real device.

Live-tested against real prod data throughout: the original add→get→update→delete→get(404) cycle on a disposable rule (before the empty-table discovery); the 9-rule restore verified both via `synthesis list` and a real `calculate_synthesized_metrics()` computation; `device list` confirmed to still show exactly 11 real devices, `"0"` excluded.

**`--priority` added the same day, per direct user request** ("priority sırasını sen hesaplanacak verilere göre düzenle") - rules are evaluated in `(Priority, ID)` order, and a rule can only see another rule's output if that rule already ran, so relying on insertion (ID) order alone is fragile. `synthesis_variable_rules.priority` turned out to already be a real DB column (`integer NOT NULL DEFAULT 1`) that `common/qapu_common/database/models.py`'s ORM model simply never declared - `get_list()` had been hardcoding `Priority=1` the whole time rather than reading it. Fixed by adding the column to the model; `get_list()`/`add()`/`update()` now read/write it for real. The 9 restored rules were backfilled with real values (independent rules = `1`, one-level-dependent rules = `2` - `VRMS_IMB`/`IRMS_IMB`/`PF_IMB`).

**Two real bugs found and fixed live while backfilling those priorities**: `Schema.Synthesis_Variable_Schema.Priority` defaulted to `1` and `.Status` defaulted to `False` (neither `None`) - `update()`'s partial-update logic treats any non-`None` value as "change this," so a Priority-only update silently reset every rule's `Status` to `False` (caught immediately: "kanka bu arada status false yapmışsın"). Fixed both defaults to `None`. A follow-up Status-only fix then *appeared* to reset every `Priority` back to `1` when read through the CLI - that one turned out to be a stale Redis cache read, not a third instance of the bug (a raw DB query confirmed `Priority` was correct throughout; `get_list(update=True)` fixed the CLI's view) - worth remembering that this system's live pipeline (`services/data`, evaluating rules on every real packet) reads through the same cache, so hand-editing rows can produce a confusing stale-vs-real mismatch mid-edit. Final state verified three ways (raw DB, forced cache refresh, CLI): all 9 rules `Status=True`, correct `Priority` split (6×`1`, 3×`2`).

**(From the original 2026-09-02 read-only version)** `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters. A real CLI bug was caught while first testing it: `Calculation` is a JSON object (e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the table crashed with `NotRenderableError: unable to render dict` until values were `json.dumps()`-ed before being handed to the table row.

**`project` (added 2026-09-02) is full read/write**, per an analyst-workflow review of the CLI ("Cinga ve diğer ürün/aile ayrımlarında proje bazlı gezinme lazım") - `Project` (`common/qapu_common/domain/project.py`) already had full CRUD (list/detail/add/update/delete) with zero Hermes/CLI wiring at all until now, same "remaining domain, wire it up" pattern as every other catalog this session. Same low-sensitivity reasoning as `group`/`manufacturer`/`calibration`: a plain project catalog (Cinga/WeatherStat/...), no device actuation, no financial/PII. `project devices <project_id>` returns full device detail (not just IDs, unlike `group devices`) via `Device.list(project_id=...)`, which already existed and already returns the full `Device_Detail_Schema` shape - more useful for an analyst than a bare ID list. Live-tested against real prod data: `project list` correctly showed the one real active project (`Cinga V1`, `id=1` - the `id=0` "Unknown" row is deliberately excluded by `Project.list()` itself); `project devices 1 --limit 3` correctly showed 3 real devices; a full add→get(confirm)→update(confirm)→delete→get(404) cycle on a disposable test project, the real `Cinga V1` row never touched.

```bash
qapu fleet data --group <group_id> [--search <term>] [--json]      # GET /hermes/data/fleet?group_id=... - latest reading per variable, every device in the group
qapu fleet data --project <project_id> [--search <term>] [--json]  # GET /hermes/data/fleet?project_id=... - same, scoped to a project instead
```

**`fleet` (added 2026-09-02) is read-only**, the third and last item from the same CLI-review doc as `project`/`--start`/`--end` - an analyst wanting one variable's latest value across every device in a group or project without N separate `qapu data <device_id>` calls ("tek tek cihaz gezmeden toplu analiz gerekir"). `fleet data` is a new top-level sub-app rather than a `qapu data fleet` subcommand of the existing `data` command, deliberately - `data` is a plain `@app.command` taking `device_id` as a positional argument, and Typer can't have a subcommand *and* a positional-arg command share one name, so making `data` itself a sub-app would have meant moving every existing `qapu data <device_id>` user onto `qapu data get <device_id>` (a real breaking change to something already documented and used throughout this file). The review doc's own naming was explicitly not prescriptive ("İsimlendirme aynen böyle olmak zorunda değil... CLI'de fleet-native bir yüzey olması önemli") - `fleet data --group/--project` delivers the same capability without breaking `data`.

Backed by a new `GET /hermes/data/fleet` endpoint (`services/api/src/routers/hermes.py`, registered *before* `/data/{device_id}` in the file so FastAPI matches the literal `fleet` path segment first, not as a `device_id` value) - takes exactly one of `group_id`/`project_id` (400 if neither or both are given), resolves the device list (`Group.list_devices()` or `Device.list(project_id=...)`, both already existing), then fans out to `Measurement_Cache.get_latest_readings()` once per device server-side and merges into one flat list, each row now carrying a `Device_ID`. Fanning out server-side (not N separate HTTP calls from the CLI) matters specifically because the CLI runs outside the Swarm entirely, often from another datacenter (the Hermes agent) - N public HTTP round trips would be far slower than N private-network Redis round trips the API server can do itself. Scope is deliberately narrow for now - latest value only, no `--last`/`--days`/`--start`/`--end` yet (not asked for in the review doc's own examples; can be added the same way `data` got them if a real need comes up). Live-tested against real prod data: `fleet data --project 1 --search VRMS_A` correctly fanned out across all 11 real Cinga devices and returned the 6 that actually had buffered `VRMS_A` data; `fleet data --group 2 --search VRMS` (the "Test Cihazları" group, one device) correctly showed all 5 VRMS_* variables for that one device; both the "neither flag" and "both flags" CLI-side validation and a real 404 (nonexistent group) confirmed. 492 tests still passing (no domain-layer changes).

```bash
qapu device-command list [--json] [--limit N]                                    # GET /hermes/device_commands
qapu device-command get <command_id> [--json]                                    # GET /hermes/device_commands/{id} - includes the full payload Template
qapu device-command add "Start" --end-point / --template '{"ID":"%ID%"}' --timeout 30 [--description]  # POST /hermes/device_commands
qapu device-command update <command_id> [--command] [--end-point] [--template] [--timeout] [--description]  # PUT /hermes/device_commands/{id}
qapu device-command delete <command_id>                                          # DELETE /hermes/device_commands/{id}
```

**`device-command` (added 2026-09-03) is full CRUD, per explicit user confirmation after a direct security check** - this is the command TEMPLATE catalog (`common/qapu_common/domain/iot.py`'s `IoT_Command` class, wrapping the `device_commands` table: `Command`/`End_Point`/`Template`/`Time_Out`/`Description`), which `IoT_Communication.send_command()` reads from to know what to actually send a device - not the actuation itself. `IoT_Command` and `IoT_Communication` are two completely separate classes in the same file; only `IoT_Command` is imported here, `IoT_Communication` (which owns `send_command()`/`send_command_mqtt()`, the real device-facing calls) is never touched. This looks close to the `iot`/`command` boundary this router has held firm on all session ("a leaked `QAPU_HERMES_KEY` should never be able to control a physical device") - flagged explicitly to the user before writing any code, since a template's `End_Point`/`Template` fields do describe the device-facing protocol; the user confirmed they specifically want the template catalog manageable via CLI, not device actuation itself, and chose full CRUD (not read-only) knowingly. Live-tested against real prod data: `device-command list` showed the 6 real templates (`Start`/`Stop`/`Update`/`Setting`/`Old_Update`/`Firmware`) untouched; `device-command get 1` showed `Start`'s real `Template` JSON; a full add→get(confirm)→update(confirm)→delete→get(404) cycle on a disposable test command, none of the 6 real templates ever modified. 492 tests still passing (no domain-layer changes - `IoT_Command` already existed and was already correct).

These six catalogs (`manufacturer`/`model`/`modem`/`sim`/`firmware`/`status`) plus `timeline` follow the exact same read-only `list`/`get` shape as `variable` - low-sensitivity reference/inventory data, safe to expose behind the Hermes shared-secret like everything else in this router. **Deliberately not extended to every domain module** - anything that can actuate a real device (`iot`/`command` - sending pump start/stop, settings pushes) or touches money/PII (`finance`/`user`) stays off this router entirely; a leaked `QAPU_HERMES_KEY` should never be able to control a physical device or read financial/personal data. See CLAUDE.md's 2026-09-01 entries for the full reasoning if this list needs revisiting.

**`manufacturer`/`model` are a third write exception alongside `group`/`infrastructure`/`equipment_type`** (added 2026-09-01) - same low-sensitivity reasoning: plain catalog rows, no device actuation, no financial/PII exposure. `manufacturer list`/`model list` also group their output by the catalog's ID-hundreds-as-category convention (e.g. `301`-`399` = "Electric Equipment") instead of a flat table: manufacturers carry an actual round-hundred row per category (`Name` **is** the category name, e.g. `(300, "Electric Equipment")`), so those header rows are looked up and excluded from their own member list; models carry no such header rows, so `model list` looks category names up from the manufacturer catalog using the same numeric convention. A category with zero members (e.g. `400`-`499` "Transformator", currently empty) is simply not printed; `--json` bypasses grouping entirely and returns the flat list as before.

**`blockchain` (added 2026-09-02) is read-only, same reasoning as the seven catalogs above** - a device's mined block history and chain-integrity status are low-sensitivity trust/observability data, no actuation, no financial/PII exposure. Mining/writing blocks stays exclusively `services/blockchain`'s job; nothing here calls `mine_block()`/`save_block()`/`delete_by_device()`. `blockchain list` shows a summary table (index/proof code/truncated previous hash/created time) since the full block payload (device connection/environment/energy snapshot at mining time) is large - use `blockchain get <device_id> <index>` for one block's full JSON. `blockchain validate` re-runs the same integrity check `Blockchain.validate_chain()` already does internally (genesis block shape, sequential indexes, previous-hash linkage, proof-of-work difficulty) and reports chain length/secure-or-not/time range - live-tested against a real device's 56-block chain, confirmed secure.

**`calibration` (added 2026-09-02) is full read/write**, per explicit user request ("kalibrasyonu da yapalım... domaindeki gibi add-delete-update vs olsun") - simple, single-resource commands (`list`/`get`/`add`/`update`/`delete`), not the flag-based consolidation `infrastructure` uses, since calibration has no sub-resource types to disambiguate. Deliberately wider than the real `/calibration` API router (`services/api/src/routers/calibration.py`), which stays read-only on purpose per that file's own header comment (a legacy behavior being preserved there, not a security judgment) - Hermes's version is a separate, additional surface, not a change to that router. Same low-sensitivity reasoning as `group`/`infrastructure`/`manufacturer`/`model`: a Gain/Offset scaling factor per device/variable, no device actuation, no financial/PII exposure. A live write through this endpoint takes effect immediately in the pipeline (goes through `Calibration.add()`/`.update()`, so the `calibration:list` Redis cache is correctly invalidated - no manual cache-bust needed here, unlike a raw-SQL migration). Live-tested a full add→get→update→get(confirm)→delete→get(404) cycle via the CLI against a local API instance, using a disposable test row on a low-traffic official test device - the real CT_RATIO/VT_RATIO rows already live on production devices were only ever read (`calibration list`), never touched.

**Real bug found and fixed the same day, right after the user actually used `--description` in production**: `--description` on `add`/`update` silently no-opped - `calibrations` never had a `description` column at all (`Schema.Calibration_Schema.Description` existed in the API contract, but `Calibration.add()`/`.update()` never read or wrote it, confirmed via `information_schema.columns`). The `Description` the CLI was already showing (always `-`) came from a completely different field - `calibration_view`'s join to `variables.description` (the variable's own description, e.g. "R Phase RMS Voltage"), not a calibration-level note. Fixed via `common/migrations/2026-09-02_add_calibration_description.py` - adds a real, nullable `calibrations.description` column and appends a new `calibration_description` column to `calibration_view` (Postgres only allows `CREATE OR REPLACE VIEW` to add columns at the end, so the existing `description`/variable-description column stays untouched at its original position). `common/qapu_common/domain/calibration.py`'s `list()`/`add()`/`update()` updated to read/write the new column; `Calibration_Schema.Description` now means what it always looked like it meant. Live-tested end to end against real prod data, including the exact commands the user had run that silently failed - `calibration update 1889/1892 --description "..."` now actually persists (confirmed via `calibration list` showing the real text and a bumped `Update_Time`, previously unchanged since 2026-08-30).

**`crop-type` (added 2026-09-02) follows `equipment-type`/`pump-type`'s exact simple catalog shape** - `Crop_Type` (`common/qapu_common/domain/crop_type.py`) is a plain ID/Name/Description catalog (what a land plot grows), full CRUD wired up the same way. Live-tested against real prod data: 46 real crop types (Buğday/Arpa/Mısır/... through fruit trees and berries) plus a full add→get→update→get(confirm)→delete→get(404) cycle on a disposable test row.

**`irrigation-type` (added 2026-09-02) follows the same simple catalog shape** - `Irrigation_Type` (`common/qapu_common/domain/irrigation_type.py`) is a plain ID/Name/Description catalog (how a land plot is irrigated - Drip/Sprinkler/...), full CRUD wired up identically to `crop-type`. Live-tested against real prod data: 5 real irrigation types (Damla/Yağmurlama/Vahşi/Pivot/Yeraltı Damla Sulama) plus a full add→get→update→get(confirm)→delete→get(404) cycle on a disposable test row. **`irrigation` (the actual irrigation-event records, as opposed to this type catalog) is a separate, much larger domain class with no add/update/delete at all** - events are derived automatically from a device's own register history by the pipeline, not entered by hand; see CLAUDE.md's entry on this if/when that one gets wired up.

**`setting` (added 2026-09-02) is device-scoped read/write, not a catalog** - `qapu setting get <device_id>`/`update` mirror the real `/setting/{device_id}` API router's own two endpoints exactly (`Device_Setting.detail()`/`.update()`), just under the Hermes shared-secret. Covers rule thresholds (8 sections, each a Min/Max pair - Temperature/Voltage/Current/Frequency/Voltage_Imbalance/Current_Imbalance/Pressure/Pressure_Slope), the register Stop/Publish bitmasks, CT ratio, and electric box auto-start delay - configuration values, no device actuation, no financial/PII exposure, same reasoning as `calibration`/`infrastructure`. `update` only sends the fields actually passed (each threshold section is Min/Max independently optional) and refuses with a clear error if called with nothing to change. Live-tested against real prod data: `setting get` correctly showed a real device's actual thresholds/register/CT ratio (including the `Register.Status` fix right below this); a full round-trip `update` (same values back) on a low-traffic test device confirmed the write path (rule value_update, calibration update) works end to end without changing anything.

**Real bug found and fixed the same day, caught by the user just reading the file in the IDE**: `Device_Setting._register()`'s SQL query never selected `register_status` - `Register.Status` had been hardcoded `None` on every `GET /setting/{device_id}` response, while the *other* place building a `Register_Schema` (`Device.get_and_cache_device()`, for `/device/{id}`) read it correctly the whole time. Fixed - see CLAUDE.md's entry for the live-verification details.

**`status` gained `add`/`update`/`delete` (added 2026-09-02)**, extending what had been list/get-only since 2026-09-01. `Status.add()` accepts an explicit `ID` (unlike the manufacturer/model category scheme, there's no automatic range computation here) - `status add "Name" --id 305` lands it at a specific ID if the caller wants to keep this catalog's existing entity-kind-by-hundred convention (`status list` shows real examples: `100`s = device, etc.); omit `--id` to fall back to the raw auto-increment sequence. Live-tested against real prod data (86 real statuses) - a full add(no explicit ID)→get→update→get(confirm)→delete→get(404) cycle, plus a separate add(`--id 9999`, an intentionally unused range)→get(confirms the exact ID)→delete cycle - both on disposable rows, no real status ever touched.

**`status list` grouped by category, same hundred convention `manufacturer`/`model` used before their 2026-09-02 widening to thousands (added same day, per direct request)**: `100`/`200`/`300` each carry a real row whose `Name` IS the category name ("Cihaz Durumları"/"Kullanıcı Durumları"/"Modem Durumları", confirmed live) - `status list` now prints one bold category heading + `(X00-X99)` range per group, header rows excluded from their own member list, same as `manufacturer list`. Reuses the exact `_print_grouped_by_thousand` pattern via a new sibling `_print_grouped_by_hundred` (`tools/cli/qapu_cli/main.py`) - status IDs were never widened to thousands, so a hundred-scoped version was needed rather than reusing the thousand one directly. `--json` stays a flat, ungrouped list. Live-tested against real prod data: 86 total rows correctly grouped into 3 categories (83 members + 3 excluded headers); `--json` still returns all 86 flat; `status get 100` (a header row itself) unaffected.

**`stream` (added 2026-09-02) is read-only** - each row is one ingested packet's transport metadata (IP/ICCID/size/process time), created automatically by `services/data` for every real device packet. `Stream.add()` exists on the domain class but is exclusively that pipeline's own job, not something a person creates by hand; no `update()`/single `delete()` exist at all (only a device-wide `delete_by_device()`, used for device cleanup) - append-only from any caller's perspective, same shape as `blockchain`. `stream list <device_id>` is paginated, most recent first; `stream get <stream_id>` (note: `/hermes/stream/detail/{id}`, same `detail/` convention as `calibration`, to avoid a path collision with the device-scoped list route) returns one row's full detail. Live-tested against real prod data - `stream list` correctly showed real ingested packets (including one from this session's own earlier live test-packet send, `IP: 192.168.114.1`, and real MQTT-delivered packets showing `IP: MQTT`); `stream get` on a real ID and a nonexistent one (404) both confirmed.

**`stream get`'s detail was extended the same day**, per user request: `Command_ID` (a bare FK integer, not meaningful on its own) is now resolved and returned as `Command_Name` instead (via `qapu_common.domain.command.Command.detail()`); a new `Variable_Count` field reports how many variables `Stream.get_stream_measurements()` actually recorded from that packet - not previously visible from `stream get` at all. `Size` is now rendered as e.g. `418 byte` in both `stream get` and `stream list`'s table (the raw `--json` output stays a plain int, only the human-readable rendering changed). The Hermes endpoint's `response_model` was dropped to `None` for this route specifically, since the response shape now deliberately diverges from the underlying `Stream_Schema` (swapped field, added field) - every other Hermes route keeps its typed `response_model`, this is a one-off. Live-tested against real prod data: `stream get 4879335` correctly showed `Command_Name: Timed` and `Variable_Count: 18`.

**`user-role` (added 2026-09-02) is the ONLY piece of the `user` domain wired up here, and deliberately so** - the user was asked explicitly first, since `user`/`finance` had been excluded from this router since it was first built (a leaked `QAPU_HERMES_KEY` - a shared secret, not real per-admin JWT - must never expose PII or financial data). Chose "read-only, PII-free fields only" as the safe subset: `User_Role` (`common/qapu_common/domain/user.py`) is a plain ID/Name/Description role catalog (admin/farmer/technician/...) with zero PII, structurally identical to `crop-type`/`pump-type`. The actual `User` class (name/phone/email) and `Authorization` (which user owns which device) are NOT wired up and were never considered - PII and device-ownership data stay off this router entirely, same as `finance`.
- **Real bug found and fixed while testing this**: `User_Role.add()` requires an explicit `ID` - unlike `Status` (which falls back to auto-increment when `ID` is omitted), `user_roles.id` is not an auto-increment column at all (`Column(Integer, primary_key=True, nullable=False)`, no `autoincrement`). The first CLI version only took `Name`/`--description`, so every `add` failed with a misleading `"User role Name is required"` (Name *was* provided - `ID` was the actual missing field). Fixed by making `id` a required positional argument on `user-role add` and clarifying the Hermes error message. Live-tested end to end against real prod data (10 real roles - admin/supervisor/technician/field_technician/accountant/dealer/farmer/farmer_employee/farmer_electrician/demo_user, confirmed zero PII, none touched): a full add(explicit ID `999`)→get→update→get(confirm)→delete→get(404) cycle on a disposable row.

**`irrigation` (added 2026-09-02) is read-only, same shape as `blockchain`/`stream`** - `Irrigation` has no `add()`/`update()`/single `delete()` at all; `update_irrigation(status_register)` derives events automatically from a device's own register history, pipeline-internal, not a user-facing write. **`Irrigation.list()` itself is deliberately NOT what backs `irrigation list`** - that method isn't device-scoped, it walks every known device's Redis cache and returns everything, which would be both expensive and the wrong shape for a per-device CLI command; `get_and_cache_irrigation(device_id=...)` (the method every other per-device caller already uses) backs this instead. `irrigation list <device_id>` shows event history (start/end/duration); `irrigation abstract <device_id>` shows the last irrigation plus 24h/today/30-day/this-month totals in one call.
- **Real CLI bug found and fixed while testing this**: the first version of `irrigation list`'s table read an `Active` field that doesn't exist on `Irrigation_Schema` at all (only `Last_Irrigation_Schema`, used by `abstract`, has one) - every row showed a blank ACTIVE column regardless of whether the irrigation was still ongoing. Fixed to derive it correctly: a row with no `End_Time` yet is the ongoing one. Live-tested against real prod data: `1000000000000001` (the only test device with real irrigation records) showed 2 real events - one finished (`Duration: 1011` min) and one correctly marked ongoing after the fix; `abstract` showed real 24h/today/30d/month totals; a device with zero records correctly showed "no records" (list) and 404 (abstract).

**`--category` is required on `manufacturer add`** (added 2026-09-01, same day as the grouped display above, after the display work immediately surfaced the gap live: a plain add landed a real row at whatever ID the table's raw auto-increment sequence handed out next, which had no relationship to the category convention - a "Lovato" panel-equipment manufacturer landed under "Pump" purely by sequence coincidence). The CLI's `--category` value (one of `electronics`/`modem`/`electric-equipment`/`transformator`/`pump`) maps to that category's round-hundred ID and is sent as a `category` query param; the domain layer (`Manufacturer.add()` in `common/qapu_common/domain/`) then computes the next free ID within that hundred and inserts explicitly at that ID, bypassing the raw sequence - refusing instead (`Invalid_Data`) if a category's 99-row range is ever exhausted. The non-Hermes `/manufacturer` API router (`require_admin`-gated) still calls the same `add()` without a category, unchanged - it falls back to the old raw-sequence placement, so a row created through it still needs a manual category-correcting fix (delete + re-add via the CLI) if it lands somewhere unintended.

**`model add` takes `--manufacturer-id`, not `--category`** (added 2026-09-02, one day later, after the same category logic shipped for `model add` too and the user immediately pointed out models should be linked to their actual manufacturer, not an arbitrary category choice - `models` had no `manufacturer_id` column at all before this). `models.manufacturer_id` (nullable FK to `manufacturers.id`, `common/migrations/2026-09-02_model_manufacturer_id.py`) is the real link; a model's category is now *derived* from its manufacturer's own ID (`manufacturer_id`'s hundred) rather than chosen separately, so a model can never end up in a different category than its manufacturer. `model list`/`model get` show a `Manufacturer_Name` (`MANUFACTURER` column in the grouped table) alongside `Manufacturer_ID`, both read-only display fields populated via a join, not stored redundantly. The 21 pre-existing rows were backfilled by name (`110`/`111` -> Ovoo Technology, `204`-`208` -> Telit, `301`-`313` -> Entes; the one pre-existing integration-test fixture row was left unlinked, `Manufacturer_ID=None`) - confirmed correct after the migration ran. **Gotcha hit during this migration, worth remembering for any future raw-SQL migration that touches `manufacturers`/`models`/similar cached catalogs**: the migration script's `UPDATE` statements don't go through `Model`'s domain layer, so they never invalidate the `model:list` Redis cache - the old (pre-`manufacturer_id`) cached list kept being served until a `Model(logger=False).list(update=True)` was run once by hand to force a refresh. A raw-SQL migration against any Redis-cached table needs that same manual cache-bust step afterward, or the fix won't be visible anywhere until something else happens to force a refresh.

**`model update --manufacturer-id` also relocates the model's ID when the change crosses a category boundary** (added 2026-09-02, right after the `add`/list changes above shipped - the CLI initially had no `--manufacturer-id` option on `update` at all, a straight oversight; fixing that immediately raised the follow-up question of what happens to a model's ID when its manufacturer moves it into a different category, since leaving the ID in place would show a stale category heading next to the new manufacturer's name in the grouped list). When the new manufacturer's category differs from the model's current ID's category, `Model.update()` (`common/qapu_common/domain/model.py`) computes the next free ID in the new category (same logic `add()` uses) and moves the row's primary key to it - **but only if nothing else references that model ID yet** (`devices.model_id`/`modems.model_id`/`box_equipment.model_id`, none of which cascade on a PK update). If the model is already in use somewhere, the manufacturer link still updates, but the ID deliberately stays put (logged, not surfaced as an error - the update still succeeds) rather than risk breaking a real referencing row or hitting a raw FK-constraint failure. `Hermes_Update_Model`'s response message says explicitly when a relocation happened (`"... relocated to ID <new_id> ..."`) so the caller knows to use the new ID afterward. Live-tested end to end: an unreferenced model moved from Telit(200s) to Entes(300s) landed at the correct next-free ID and the old ID immediately 404'd; the referenced-model path (link updates, ID stays) is covered by unit tests in `common/tests/test_model.py`, not separately live-verified against a real referencing row.

```bash
qapu equipment-type list [--json] [--limit N]       # GET /hermes/equipment_types - CT/contactor/breaker/VFD/... catalog
qapu equipment-type get <id> [--json]               # GET /hermes/equipment_types/{id}
qapu equipment-type add "VFD" --description "..."   # POST /hermes/equipment_types - a new kind is just a new row

qapu pump-type list [--json] [--limit N]            # GET /hermes/pump_types - submersible/centrifugal/... catalog
qapu pump-type get <id> [--json]                    # GET /hermes/pump_types/{id}
qapu pump-type add "Submersible" --description "..." # POST /hermes/pump_types - a new kind is just a new row
qapu pump-type update <id> [--name] [--description]  # PUT /hermes/pump_types/{id} - only passed fields change
qapu pump-type delete <id>                            # DELETE /hermes/pump_types/{id}

qapu infrastructure get <device_id> [--json]        # GET /hermes/infrastructure/{device_id} - tree view

# add/update take exactly one resource-type flag instead of a separate verb per
# resource (add-transformer, add-electric-box, ... consolidated 2026-09-02, see below)
qapu infrastructure add --transformer [--capacity] [--manufacturer-id]                                            # POST .../transformer, prints ID
qapu infrastructure add --electric-box --transformer-id <id> [--auto-start-delay]                                 # POST .../electric_box, prints ID
qapu infrastructure add --equipment --box-id <id> --type-id <id> [--manufacturer-id] [--model-id] [--specs '{...}'] [--notes]   # POST .../electric_box/{box_id}/equipment, prints ID
qapu infrastructure add --pump --box-id <id> [--power] [--flow-rate] [--pipe-diameter] [--type-id] [--manufacturer-id]          # POST .../pump, prints ID
qapu infrastructure add --land-plot --device-id <id> --irrigation-type-id <id> --crop-type-id <id> [--parcel] [--area] [--neighborhood-id]   # POST .../{device_id}/land_plot

qapu infrastructure assign <device_id> --pump <pump_id>               # PUT .../{device_id}/pump/{pump_id}
qapu infrastructure assign <device_id> --transformer <transformer_id> # PUT .../{device_id}/transformer/{transformer_id}

qapu infrastructure delete <device_id>                    # DELETE .../{device_id} - unlink + delete the whole chain
qapu infrastructure delete <device_id> --equipment <id>   # DELETE .../{device_id}/electric_box/equipment/{id} - just that one item

qapu infrastructure update <device_id> --transformer [--capacity] [--manufacturer-id]
qapu infrastructure update <device_id> --electric-box [--auto-start-delay]
qapu infrastructure update <device_id> --pump [--power] [--flow-rate] [--pipe-diameter] [--type-id] [--manufacturer-id]
qapu infrastructure update <device_id> --equipment --equipment-id <id> [--type-id] [--manufacturer-id] [--model-id] [--specs '{...}'] [--notes]
qapu infrastructure update <device_id> --land-plot --land-plot-id <id> [--irrigation-type-id] [--crop-type-id] [--parcel] [--area] [--neighborhood-id]
```

Shows/manages a device's electrical infrastructure - either the pump it controls (with its electric box, transformer, installed equipment, and any land plot(s) it irrigates) or the transformer it monitors directly (an OG/medium-voltage device, no pump/box). **A second deliberate write exception alongside `group`** (added the same day, after reconsidering) - the standalone transformer/electric-box/pump creation don't even exist on the real `require_device_owner`-gated `/infrastructure` API, since "create a transformer not yet linked to anyone" doesn't fit a device-owner permission model anyway; the rest (assignment/updates/land-plot/equipment CRUD) mirror that real API's shape 1:1, just under the Hermes shared-secret instead. Chosen specifically so the team (or an AI agent, given a natural-language description of a panel/pump/transformer) can record real infrastructure data through the CLI without needing a JWT login flow that doesn't exist yet.

**`add`/`update`/`assign`/`delete` were consolidated from 15 separate `add-*`/`update-*`/`assign-*`/`delete-*` commands down to these 4 (2026-09-02)**, per explicit user feedback that the original shape ("add-electric-box", "update-pump", "assign-transformer", ...) had gotten confusing - fifteen verb-per-resource commands under one subcommand group is a lot to remember. Each of the 4 now takes exactly one resource-type flag (`--transformer`/`--electric-box`/`--equipment`/`--pump`/`--land-plot` for `add`/`update`; `--pump`/`--transformer` for `assign`; `--equipment` optional on `delete`, its absence meaning "the whole chain" - confirmed with the user rather than assumed) instead of a different verb per resource - refuses with a clear error if zero or more than one flag is given. This makes the resource type the thing that varies, not the command name, which is both fewer top-level commands to discover and closer to how someone would describe the action out loud ("add a transformer", not "add-transformer"). Live-tested end to end via a full build-up-then-teardown chain against a local API instance (transformer → electric box → equipment → pump → assign to a device → land plot → `get` tree confirms all of it → update every resource type → delete the equipment → delete the whole chain → `get` confirms empty again), plus the "pick exactly one flag" validation on both `add` (zero flags, and two flags at once) and `assign` (both `--pump` and `--transformer` together).

A typical build-up sequence: `add-transformer` → `add-electric-box --transformer-id <id>` → `add-equipment <box_id> --type-id <id>` (repeat per item) → `add-pump --box-id <id>` → `assign-pump <device_id> <pump_id>` → `add-land-plot <device_id> --irrigation-type-id <id> --crop-type-id <id>`. Every `add-*` command prints the ID of what it just created - needed for the next step in the chain.

## `qapu health`

Rewrote `health` (2026-09-02) - it used to just dump the raw `GET /health` JSON blob unconditionally, even without `--json` (caught live: "health kısmı json görünüyor ben json istemeden"). Now shows, in plain readable text: the CLI's own installed version, whether a newer `qapu-cli` is available on PyPI (checked via `https://pypi.org/pypi/qapu-cli/json`, best-effort - a slow/unreachable PyPI just silently skips the notice, never blocks the health check itself) with the exact upgrade command to run, the configured API URL, connectivity with round-trip time, and the server's own status (version/hostname/IP/uptime/CPU/memory) - not just the raw JSON. `--json` still returns the full structured payload (`CLI_Version`/`Latest_Version`/`Update_Available`/`API_URL`/`Connected`/`Response_Time_MS`/`Server`/`Error`) for scripting. Exit code 1 on a failed connection, unchanged from before. Live-tested against the real production API (`api.ovoo.com.tr`, no VPN needed - `/health` has no auth): correctly showed a real update notice (local dev install at `0.1.0` vs. PyPI's `0.5.0`), real server stats, and a real connection-refused error case with the correct exit code.

## `--json` on `get` commands

Every simple `<noun> get` command (manufacturer/model/modem/sim/firmware/status/equipment-type/pump-type/calibration/crop-type/irrigation-type/user-role/blockchain/stream) supports `--json` for raw JSON output; without it, the result prints as readable `Key: Value` lines (nested objects indented one level per level, via a shared `_print_detail()` helper) - matching how every `list` command already behaves. **Fixed 2026-09-02** - most of these had `as_json: bool = typer.Option(False, "--json")` declared but never actually checked (always printed raw JSON regardless of the flag), and `blockchain get`/`stream get` didn't even declare the flag at all. Caught live by the user running `qapu stream get <id>` without `--json` and getting JSON back anyway.

## Error output and request timeouts

Rewrote error handling in `tools/cli/qapu_cli/client.py` (2026-09-03), per direct request ("cli hata dönüşlerinde sadece hata yazıyor... insan gözünde düzgün gelecek şekilde kırmızı tablolu vs şekle getirelim tamamında görüyorum bunu"). Every command already went through this one file for HTTP calls, so fixing it here fixed every command's error output at once - no changes needed in `main.py`. A failed request now prints a bold red-bordered `Panel` (Rich, to stderr) instead of a bare `Error: ...` line - HTTP errors show the status code, method+URL, and the server's own message (pulled from the JSON body's `message`/`Message` field if present, raw text otherwise); connection failures show method+URL and a plain-language reason. Exit code stays `1` in every case, unchanged - only the rendering changed, so nothing scripting against the CLI's exit status breaks.

**Also added a request timeout, split into connect vs. read** (`httpx.Timeout(connect=2.0, read=30.0, write=10.0, pool=5.0)`) - previously a bare `timeout=30` meant a genuinely unreachable server (VPN down, wrong URL, dead host) took the full 30 seconds to report anything, far longer than a CLI user actually waits before assuming something's wrong. `connect=2.0` (2 seconds, per the user's own suggestion - "2 sn olabilir mesela") fails fast specifically when nothing is answering at all; `read=30.0` is left generous so a request that's genuinely still being processed server-side (e.g. `fleet data`, which fans out across every device in a group before responding) isn't cut off mid-flight just because it's slow, not dead. `httpx.ConnectTimeout`/`ReadTimeout` are caught separately from other `RequestError`s and get their own specific message ("Sunucuya N saniye içinde bağlanılamadı" / "Sunucu N saniye içinde yanıt vermedi") rather than a generic connection-error string.

Live-tested: a real 404 (`synthesis get 99999`) rendered as a red panel with the real "Synthesis rule not found." message; a connection-refused case (wrong port) rendered as a red panel with the OS-level error; an unreachable IP (`10.255.255.1`, never responds) correctly failed in ~2.5s with the specific connect-timeout message, instead of hanging for 30s as before; the missing-`QAPU_HERMES_KEY` case also now renders as a panel instead of a bare stderr line.

## Auth (current placeholder - read this before pointing at production)

`api.ovoo.com.tr` is genuinely public on the internet. The Hermes endpoints (`services/api/src/routers/hermes.py`) are gated by `require_hermes_key` (`services/api/src/dependencies.py`) - a single shared-secret string compared against the `X-Hermes-Key` header, checked via the `HERMES_SHARED_SECRET` env var on the API side. This is **deliberately temporary**: it exists only so the CLI/API plumbing could be built and tested end-to-end before the real auth design was ready, not because a shared secret is considered good enough long-term.

**Real plan** (not built yet - phase 2, along with the score/comment table Hermes will eventually write to): an admin-role `hermes-qapu` account in the `users` table, with the CLI gaining a `qapu login` command that authenticates through the existing `JWT_Auth` flow every other Qapu client already uses, storing a short-lived token instead of a static shared secret. `client.py` is written so only it needs to change when that lands - nothing in `main.py` should need to know how auth works under the hood.

Until then: `HERMES_SHARED_SECRET` fails closed (unset = every Hermes request rejected, never silently open), but a leaked shared-secret string is a much blunter credential than a scoped, revocable JWT - don't treat this as production-grade access control.

## Running locally

```bash
QAPU_API_URL=http://localhost:8000 QAPU_HERMES_KEY=dev-secret python -m qapu_cli.main device list
```

(or, once installed via `pip install -e .`: just `qapu devices list` with the same env vars set.)
