Metadata-Version: 2.4
Name: qapu-cli
Version: 0.6.0
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.).

**`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 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}
```

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

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

**`synthesis` (added 2026-09-02) is read-only, `list` only** - `synthesis_variable_rules` (how a derived metric like `VRMS_A`/`AE_TOT`/power-factor-imbalance gets calculated from raw variables) has no `add()`/`update()`/`delete()` on the domain class at all, it's a small, directly-DB-managed rule set. `Device_ID='0'` rows are global (applied to every device); a real device ID scopes a rule to just that device. `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters, showing ID/Device/Variable/Priority/Required Variables/Calculation/Active in a table. **Real CLI bug found and fixed while testing this**: `Calculation` is a JSON object (the actual formula, e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the first version of this table crashed with `NotRenderableError: unable to render dict` the moment it hit a real row; fixed by `json.dumps()`-ing dict values before handing them to the table. Live-tested against real prod data after the fix: all 12 real global synthesis rules rendered correctly (readable formulas), `--active`/`--variable-id` filters both confirmed correct counts.

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

## 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.)
