Metadata-Version: 2.4
Name: qapu-cli
Version: 0.10.1
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`. |
| `QAPU_FTP_ADMIN_PASSWORD` | The `cinga_ftp_admin` FTP account's password - only needed for `qapu firmware upload` (talks to `deploy/ops/ftp/` directly, not through the API). Never hardcoded, same "env var, not committed" convention as `QAPU_HERMES_KEY`. |

## Commands

**Full command reference (100 commands, auto-generated from `main.py`'s docstrings on 2026-09-04 - regenerate the same way if this drifts, don't hand-maintain)**: every command below has `--help` for its full flag list; commands under a resource group (`device`, `variable`, `group`, ...) follow `qapu <group> <command> ...`. The rest of this section gives worked examples and deeper narrative for the commands with more going on (`data`/`trend`/`energy`/`voltage`/`current`/`power`/`ct-check`/`fleet`/`infrastructure`/etc.) - if a command below isn't mentioned again further down, its one-line description here plus `--help` is the whole story, there's nothing more to it.

#### Top-level (device-scoped or global)

| Command | Description |
|---|---|
| `qapu ct-check` | Diagnose CT (current transformer) wiring per phase - reversed polarity vs. wrong phase order - from live PF_R/S/T/AE_R/S/T (GET /hermes/data/{device_id}). |
| `qapu current` | Current synthesis parameters, grouped (RMS, imbalance, THD, crest factor, fundamental component) (GET /hermes/data/{device_id}). |
| `qapu data` | Readings per variable for a device - latest value, last N buffered readings (--last), daily min/avg/max (--days), an explicit --start/--end range, or a live-refreshed view (--watch) (GET /hermes/data/{device_id}). |
| `qapu energy` | One day's active/reactive energy breakdown per phase (GET /hermes/energy/{device_id}). |
| `qapu health` | Check CLI/server version, connectivity, and server status (GET /health, no auth required). |
| `qapu pipeline` | Fleet-wide pipeline throughput for a time window - ingest/data/rule/blockchain packet+event counts (GET /hermes/pipeline/stats). `--watch` live-refreshes in place. |
| `qapu power` | Power synthesis parameters, grouped (active/reactive/apparent, imbalance, power factor) (GET /hermes/data/{device_id}). |
| `qapu timeline` | Get one device's timeline events (GET /hermes/timeline/{device_id}). |
| `qapu trend` | Trend for one variable on one device: recent readings, daily min/avg/max, or an explicit --start/--end range (GET /hermes/trend/{device_id}/{variable_id}). |
| `qapu voltage` | Voltage synthesis parameters, grouped (RMS, imbalance, THD, fundamental component) (GET /hermes/data/{device_id}). |

#### `device` - device inventory

| Command | Description |
|---|---|
| `qapu device get` | Get one device's detail (GET /hermes/devices/{device_id}). |
| `qapu device list` | List every device Qapu knows about (GET /hermes/devices). |

#### `variable` - variable catalog

| Command | Description |
|---|---|
| `qapu variable get` | Get one variable's detail (GET /hermes/variables/{variable_id}). |
| `qapu variable list` | List the variable catalog (GET /hermes/variables). |

#### `group` - device groups

| Command | Description |
|---|---|
| `qapu group add` | Create a new device group (POST /hermes/groups). |
| `qapu group assign` | Assign a device to a group (POST /hermes/groups/{group_id}/devices/{device_id}). |
| `qapu group devices` | List the device IDs assigned to one group (GET /hermes/groups/{group_id}/devices). |
| `qapu group get` | Get one group's detail (GET /hermes/groups/{group_id}). |
| `qapu group list` | List device groups (GET /hermes/groups). |
| `qapu group unassign` | Remove a device's assignment from a group (DELETE /hermes/groups/{group_id}/devices/{device_id}). |
| `qapu group update` | Update an existing group - only the fields passed are changed (PUT /hermes/groups/{group_id}). |

#### `manufacturer` - manufacturer catalog

| Command | Description |
|---|---|
| `qapu manufacturer add` | Create a new manufacturer (POST /hermes/manufacturers). |
| `qapu manufacturer delete` | Delete an existing manufacturer (DELETE /hermes/manufacturers/{manufacturer_id}). |
| `qapu manufacturer get` | Get one manufacturer's detail (GET /hermes/manufacturers/{manufacturer_id}). |
| `qapu manufacturer list` | List the manufacturer catalog, grouped by category (GET /hermes/manufacturers). |
| `qapu manufacturer update` | Update an existing manufacturer - only the fields passed are changed (PUT /hermes/manufacturers/{manufacturer_id}). |

#### `model` - hardware model catalog

| Command | Description |
|---|---|
| `qapu model add` | Create a new hardware model, linked to its manufacturer (POST /hermes/models). |
| `qapu model delete` | Delete an existing hardware model (DELETE /hermes/models/{model_id}). |
| `qapu model get` | Get one hardware model's detail (GET /hermes/models/{model_id}). |
| `qapu model list` | List the hardware model catalog, grouped by the same thousands-as-category convention as manufacturers (GET /hermes/models). |
| `qapu model update` | Update an existing hardware model - only the fields passed are changed (PUT /hermes/models/{model_id}). |

#### `modem` - modem inventory

| Command | Description |
|---|---|
| `qapu modem get` | Get one modem's detail by IMEI (GET /hermes/modems/{imei}). |
| `qapu modem list` | List the modem inventory (GET /hermes/modems). |

#### `sim` - SIM inventory

| Command | Description |
|---|---|
| `qapu sim get` | Get one SIM's detail by ICCID (GET /hermes/sims/{iccid}). |
| `qapu sim list` | List the SIM inventory (GET /hermes/sims). |

#### `firmware` - firmware catalog

| Command | Description |
|---|---|
| `qapu firmware get` | Get one firmware version's detail (GET /hermes/firmware/{version}). |
| `qapu firmware list` | List the firmware catalog (GET /hermes/firmware). |
| `qapu firmware add` | Create a new firmware catalog entry (POST /hermes/firmware). |
| `qapu firmware update` | Update an existing firmware catalog entry (PUT /hermes/firmware/{version}). |
| `qapu firmware delete` | Delete a firmware catalog entry - does not delete the underlying file (DELETE /hermes/firmware/{version}). |
| `qapu firmware upload` | Publish a real firmware file end to end - MD5, FTP upload, catalog registration, all in one step. |

#### `status` - device/modem status catalog

| Command | Description |
|---|---|
| `qapu status add` | Create a new status (POST /hermes/statuses). |
| `qapu status delete` | Delete an existing status (DELETE /hermes/statuses/{status_id}). |
| `qapu status get` | Get one status's detail (GET /hermes/statuses/{status_id}). |
| `qapu status list` | List the device/modem status catalog, grouped by category (GET /hermes/statuses). |
| `qapu status update` | Update an existing status - only the fields passed are changed (PUT /hermes/statuses/{status_id}). |

#### `equipment-type` - box equipment type catalog

| Command | Description |
|---|---|
| `qapu equipment-type add` | Create a new equipment type - use before recording box equipment of a kind that doesn't exist in the catalog yet (POST /hermes/equipment_types). |
| `qapu equipment-type get` | Get one equipment type's detail (GET /hermes/equipment_types/{id}). |
| `qapu equipment-type list` | List the box equipment type catalog (GET /hermes/equipment_types). |

#### `pump-type` - pump type catalog

| Command | Description |
|---|---|
| `qapu pump-type add` | Create a new pump type - use before recording a pump of a kind that doesn't exist in the catalog yet (POST /hermes/pump_types). |
| `qapu pump-type delete` | Delete an existing pump type (DELETE /hermes/pump_types/{pump_type_id}). |
| `qapu pump-type get` | Get one pump type's detail (GET /hermes/pump_types/{id}). |
| `qapu pump-type list` | List the pump type catalog (GET /hermes/pump_types). |
| `qapu pump-type update` | Update an existing pump type - only the fields passed are changed (PUT /hermes/pump_types/{pump_type_id}). |

#### `infrastructure` - transformer/box/pump/equipment/land plot

| Command | Description |
|---|---|
| `qapu infrastructure add` | Create standalone infrastructure (transformer/electric box/equipment/pump), or add a land plot to a device's pump. Pick exactly one of --transformer/--electric-box/--equipment/--pump/--land-plot. |
| `qapu infrastructure assign` | Assign a device to control an existing pump, or monitor a transformer directly. Pick exactly one of --pump or --transformer. |
| `qapu infrastructure delete` | Delete one piece of box equipment (--equipment), or unlink/delete a device's whole infrastructure chain (DELETE /hermes/infrastructure/...). |
| `qapu infrastructure get` | Get one device's infrastructure - pump/box/transformer/equipment/land plots, or a direct transformer link (GET /hermes/infrastructure/{device_id}). |
| `qapu infrastructure update` | Update detail fields on a device's infrastructure. Pick exactly one of --transformer/--electric-box/--equipment/--pump/--land-plot. |

#### `blockchain` - mined block ledger

| Command | Description |
|---|---|
| `qapu blockchain get` | Get one device's block at a given index, full payload (GET /hermes/blockchain/{device_id}/{index}). |
| `qapu blockchain list` | List every mined block for a device, genesis to latest (GET /hermes/blockchain/{device_id}). |
| `qapu blockchain validate` | Validate a device's mined block chain - length, secure/tampered status, and time range (GET /hermes/blockchain/{device_id}/validate). |

#### `calibration` - Gain/Offset per variable

| Command | Description |
|---|---|
| `qapu calibration add` | Create a new calibration row for a device/variable (POST /hermes/calibration). |
| `qapu calibration delete` | Delete an existing calibration row (DELETE /hermes/calibration/{calibration_id}). |
| `qapu calibration get` | Get one calibration row's detail (GET /hermes/calibration/detail/{calibration_id}). |
| `qapu calibration list` | List a device's calibration rows - Gain/Offset per variable (GET /hermes/calibration/{device_id}). |
| `qapu calibration update` | Update an existing calibration row - only the fields passed are changed (PUT /hermes/calibration/{calibration_id}). |

#### `crop-type` - crop type catalog

| Command | Description |
|---|---|
| `qapu crop-type add` | Create a new crop type - use before recording a land plot growing a crop that doesn't exist in the catalog yet (POST /hermes/crop_types). |
| `qapu crop-type delete` | Delete an existing crop type (DELETE /hermes/crop_types/{crop_type_id}). |
| `qapu crop-type get` | Get one crop type's detail (GET /hermes/crop_types/{id}). |
| `qapu crop-type list` | List the crop type catalog (GET /hermes/crop_types). |
| `qapu crop-type update` | Update an existing crop type - only the fields passed are changed (PUT /hermes/crop_types/{crop_type_id}). |

#### `irrigation-type` - irrigation method catalog

| Command | Description |
|---|---|
| `qapu irrigation-type add` | Create a new irrigation type - use before recording a land plot using a method that doesn't exist in the catalog yet (POST /hermes/irrigation_types). |
| `qapu irrigation-type delete` | Delete an existing irrigation type (DELETE /hermes/irrigation_types/{irrigation_type_id}). |
| `qapu irrigation-type get` | Get one irrigation type's detail (GET /hermes/irrigation_types/{id}). |
| `qapu irrigation-type list` | List the irrigation type catalog (GET /hermes/irrigation_types). |
| `qapu irrigation-type update` | Update an existing irrigation type - only the fields passed are changed (PUT /hermes/irrigation_types/{irrigation_type_id}). |

#### `setting` - rule thresholds, register, electric box

| Command | Description |
|---|---|
| `qapu setting get` | Get a device's editable settings - thresholds, register, electric box (GET /hermes/setting/{device_id}). |
| `qapu setting update` | Update a device's editable settings - only the fields passed are changed (PUT /hermes/setting/{device_id}). |

#### `stream` - ingested packet history

| Command | Description |
|---|---|
| `qapu stream get` | Get one stream row's detail - command name and variable count included (GET /hermes/stream/detail/{stream_id}). |
| `qapu stream list` | List a device's ingested packet stream history, most recent first (GET /hermes/stream/{device_id}). |

#### `synthesis` - derived-metric calculation rules

| Command | Description |
|---|---|
| `qapu synthesis add` | Create a new synthesis rule (POST /hermes/synthesis). |
| `qapu synthesis delete` | Delete an existing synthesis rule (DELETE /hermes/synthesis/{rule_id}). |
| `qapu synthesis get` | Get one synthesis rule's detail (GET /hermes/synthesis/{rule_id}). |
| `qapu synthesis list` | List derived-metric calculation rules (GET /hermes/synthesis). |
| `qapu synthesis update` | Update an existing synthesis rule - only the fields passed are changed (PUT /hermes/synthesis/{rule_id}). |

#### `user-role` - user role catalog (Admin/User/...)

| Command | Description |
|---|---|
| `qapu user-role add` | Create a new user role (POST /hermes/user_roles). |
| `qapu user-role delete` | Delete an existing user role (DELETE /hermes/user_roles/{role_id}). |
| `qapu user-role get` | Get one user role's detail (GET /hermes/user_roles/{role_id}). |
| `qapu user-role list` | List the user role catalog, e.g. Admin/User (GET /hermes/user_roles). |
| `qapu user-role update` | Update an existing user role - only the fields passed are changed (PUT /hermes/user_roles/{role_id}). |

#### `irrigation` - irrigation event history

| Command | Description |
|---|---|
| `qapu irrigation abstract` | Get a device's irrigation summary - last irrigation + 24h/today/30d/month totals (GET /hermes/irrigation/{device_id}/abstract). |
| `qapu irrigation list` | List a device's irrigation event history - start/end/duration (GET /hermes/irrigation/{device_id}). |

#### `project` - project catalog (Cinga, WeatherStat, ...)

| Command | Description |
|---|---|
| `qapu project add` | Create a new project (POST /hermes/projects). |
| `qapu project delete` | Delete an existing project (DELETE /hermes/projects/{project_id}). |
| `qapu project devices` | List every device belonging to one project, full detail (GET /hermes/projects/{project_id}/devices). |
| `qapu project get` | Get one project's detail (GET /hermes/projects/{project_id}). |
| `qapu project list` | List the project catalog (GET /hermes/projects). |
| `qapu project update` | Update an existing project - only the fields passed are changed (PUT /hermes/projects/{project_id}). |

#### `fleet` - bulk, multi-device queries

| Command | Description |
|---|---|
| `qapu fleet data` | Latest reading per variable, across every device in a group or project (GET /hermes/data/fleet). |
| `qapu fleet status` | Fleet-wide (or group/project-scoped) online/offline summary. `--watch` live-refreshes in place (GET /hermes/devices). |

#### `device-command` - command template catalog, plus real actuation via `send`

| Command | Description |
|---|---|
| `qapu device-command add` | Create a new device command template (POST /hermes/device_commands). |
| `qapu device-command delete` | Delete an existing device command template (DELETE /hermes/device_commands/{command_id}). |
| `qapu device-command get` | Get one device command template's detail, including its full payload Template (GET /hermes/device_commands/{id}). |
| `qapu device-command list` | List the device command template catalog (GET /hermes/device_commands). |
| `qapu device-command update` | Update an existing device command template - only the fields passed are changed (PUT /hermes/device_commands/{command_id}). |
| `qapu device-command send` | Send a real command to a device - HTTP (template catalog) by default, `--mqtt` for start/stop/reboot/restart/force over the broker, matching the firmware's real contract (POST /hermes/iot/send or /hermes/iot/mqtt/command). |
| `qapu device-command settings` | Send a `settings` command over MQTT - only the fields you pass are changed on the device (POST /hermes/iot/mqtt/settings/{device_id}). |
| `qapu device-command fotahttp` | Send a `fotahttp` command over MQTT - device downloads firmware via HTTP (POST /hermes/iot/mqtt/fotahttp/{device_id}). |
| `qapu device-command fotaftp` | Send a `fotaftp` command over MQTT - device downloads firmware via FTP (POST /hermes/iot/mqtt/fotaftp/{device_id}). |

### Worked examples and deeper detail

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

**`qapu data --watch`/`-w` (added 2026-09-04)** - the same live-refresh idea `qapu pipeline --watch` uses (see that command's own entry further down for the underlying pattern), applied to a single device's latest readings instead of the fleet-wide pipeline summary, per a same-day follow-up ("aynı şekilde data izlerken de bir canlı uç açabilirmiyiz"):
```bash
qapu data <device_id> --watch                    # every variable, live
qapu data <device_id> --watch --search VRMS       # combine with family/search filters, same as normal mode
qapu data <device_id> --watch --interval 2        # poll every 2s instead of the default 5s
```
Only valid for the default latest-value mode - refuses clearly if combined with `--last`/`--days`/`--start`/`--end`/`--json`. Filtering logic (`--energy`/`--gsm`/`--voltage`/`--current`/`--battery`/`--search`) was extracted into a shared `_apply_data_filters()` so the watch loop and the normal path can't drift apart. Each poll after the first shows a `DEĞİŞİM` column - green `▲ <diff>` if a variable rose, red `▼ <diff>` if it fell - and a live `Son paket: Xsn/Xdk önce` line above the table, recomputed to the second on every render (via a new fine-grained `_elapsed_seconds_label()`, distinct from `_relative_time()`'s minute-or-coarser bucket) so it visibly ticks between packets instead of sitting frozen.

**Real bug found and fixed live the same session, right after shipping**: the first version compared each poll's value against the *immediately prior poll's* value - since most polls land between two real device packets (identical underlying reading), the ▲/▼ indicator flashed for exactly one refresh, then collapsed back to "unchanged" even though nothing new had actually happened ("son veri gelince değişim gösteriyor ama sonra gidiyor... yeni veri gelene kadar kalsın"). Fixed by keying the comparison on the reading's own `Time` (the real device-packet timestamp) instead of the poll clock: `_build_data_watch_table()` now takes a persistent `state` dict (`variable_id -> {Value, Time, Delta}`, mutated in place across polls by the caller, not passed fresh each time) - the rendered delta is only *recomputed* when a variable's `Time` actually advances (a genuinely new packet), and otherwise carried forward unchanged, so it stays visible until the next real update rather than the next poll. Verified directly: four synthetic polls fed straight into `_build_data_watch_table()` (first-ever reading -> a second poll with the identical packet Time, confirming the delta column appears but shows nothing to compare yet -> a third poll with a genuinely new Time and a higher value, confirming a fresh green `▲` -> a fourth poll repeating that same new Time, confirming the `▲` *persists* instead of reverting to blank/`·`) - all four behaved exactly as intended. `rich.Live`'s actual screen-redraw behavior in a real terminal wasn't separately re-verified end to end, for the same non-TTY sandbox reason noted under `pipeline --watch`.

**`data`'s latest-value tables (both the default one-shot view and `--watch`) split into "Faz Bazlı (R/S/T)" / "Genel / Hesaplanan" groups, plus a small inline BAR column (2026-09-04)** - direct feedback on a real `--search VRMS --watch` screenshot: raw per-phase readings (`VRMS_R`/`VRMS_S`/`VRMS_T`) were interleaved alphabetically with their own derived siblings (`VRMS_A`/`VRMS_EQ`/`VRMS_IMB`/`VRMS_SR`), and the user asked (half-joking, "çok mu abartıyorum") whether grouping and/or a small bar graph would help. Both, kept simple: a new `_is_phase_variable()` helper (`tools/cli/qapu_cli/main.py`, a variable ID ending in `_R`/`_S`/`_T`) splits any reading list into two groups - deliberately generic, not VRMS-specific, so it groups `IRMS_R/S/T`, `PF_R/S/T`, `VTHD_R/S/T`, etc. the same way. Each group becomes its own titled table (`_print_data_table()` for the default view, the same split folded into `_build_data_watch_table()` for `--watch`) rather than one flat list. `BAR` is a small unicode block bar (`_relative_bar()`, 10 chars, cyan) scaled to the largest same-unit value *within that group* (`_max_abs_by_unit()`) - not global across the whole device, so a 234V phase reading and a 0.1% imbalance value never get compared against each other's bar. Live-tested against real prod data (`400000011D081B70`, `--search VRMS`, both the one-shot table and `--watch`): phase readings render together with near-full bars (correctly showing they're all close to each other), `VRMS_IMB`/`VRMS_SR` render together in their own group with bars reflecting their actual relative sizes, and a live watch session confirmed the split holds up across repeated polls with the delta column unaffected.

```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 energy` (added 2026-09-04)** - one day's active/reactive energy breakdown per phase (`GET /hermes/energy/{device_id}`, `--date YYYY-MM-DD`, defaults to today):
```bash
qapu energy <device_id>                # today's breakdown
qapu energy <device_id> --date 2026-09-01
qapu energy <device_id> --json
```
This one is **not** a synthesis rule, unlike almost everything else in this session's synthesis-parameter work - `Synthesized_Metrics.calculate_synthesized_metrics()` is stateless (only ever sees the current packet's `data_set`), and the Energy segment's formulas (`E_P,Σ/A/imb`, `ΔE_P,RS/ST/TR`, `Share_E,x`, `E_cap/ind,Σ`, `E_Q,net`, `K_Q/P,E`, `IndDom/CapDom_E`) are all built on `ΔE_P,R` - the *change* in a cumulative meter reading between packets, which a stateless-per-packet rule genuinely cannot compute (no formula-only workaround here, unlike `h_dom`'s soft-argmax trick - this needs a previous value, not a cleverer expression of the current one). Since `AE_R/S/T`/`RE_L_R/S/T`/`RE_G_R/S/T` are monotonically-increasing cumulative meter readings (Wh/VARh) and `Measurement_Cache.update_analytics()` already tracks each day's Min/Max per variable (for exactly this reason), a day's consumption is simply that day's `Max - Min` - pure query-time computation over data already cached, no ingest/pipeline change, no new `synthesis_variable_rules` row. New `Measurement_Cache.get_daily_energy_summary(date)` (`common/qapu_common/domain/measurement_cache.py`) + `GET /hermes/energy/{device_id}` (`services/api/src/routers/hermes.py`). `RE_L` (Leading) is capacitive, `RE_G` (Lagging) is inductive - standard AC convention, confirmed against the doc's own "Motorlu yüklerin baskın olduğu" (motor-load-dominant) framing, since motors are inductive loads and Cinga's real devices are pump-heavy. Live-tested end to end against a local API instance + real prod Redis: sent two real HTTP packets to a disposable test device (`1000000000000003`) with rising `AE_R/S/T`/`RE_L_R/S/T`/`RE_G_R/S/T` values, confirmed all 15 derived fields match hand-computed expected values exactly (e.g. `E_P_TOT=108.0`, `INDDOM_E_PCT=94.1176`), plus the "device not found" (404) and "no data that day" (plain message, not an error) paths.

**`qapu voltage`/`qapu current`/`qapu power` (added 2026-09-04)** - the same synthesis parameters `qapu data <id> --search ...` already exposes, grouped into curated, labeled sections instead of one flat table of cryptic IDs:
```bash
qapu voltage <device_id>            # latest values, compact (RMS, imbalance, phase deviation, THD, harmonic centroid, fundamental component)
qapu voltage <device_id> --full     # also per-phase/per-harmonic detail (harmonic ratios by order 3/5/7/9, pairwise phase differences)
qapu voltage <device_id> --days 7   # daily min/avg/max per variable instead of latest, same sections
qapu voltage <device_id> --json

qapu current <device_id>            # same idea, plus a Crest Factor section
qapu power <device_id>              # active/reactive/apparent power, imbalance, power factor family, fundamental totals
```
Unlike `qapu energy`, these are **not** daily-by-default - voltage/current/power are instantaneous quantities, not cumulative meters, so there's no natural "daily total" the way energy has one (confirmed with the user before building: "zaman seçimsel olsun... saatlik günlük haftalık" was the ask, but a real hourly bucket doesn't exist anywhere in the cache today, only daily - so `--days N` reuses the exact same vocabulary `data`/`trend` already use rather than inventing a new period concept). Pure CLI-side grouping over the existing `/hermes/data/{device_id}` response - zero new backend logic, each command is a hardcoded `(section title, [Variable_ID, ...])` list in `tools/cli/qapu_cli/main.py` (`VOLTAGE_SECTIONS_COMPACT`/`_FULL_EXTRA`, same pattern for `CURRENT_`/`POWER_`), a section is silently skipped if none of its variables are in the device's reading list. Live-tested against real prod data: a single packet covering the full Voltage+Current+Power raw variable set sent to a disposable test device, all three commands' compact and `--full`/`--json`/`--days` modes confirmed rendering correctly, plus the device-not-found (404) path.

**`qapu ct-check` (added 2026-09-04)** - field CT (current transformer) wiring diagnostic, per direct field request ("cihazımız üzerinde 3 adet akım trafosu girişi var... akım trafosu yönleri gibi durumlar oluyor... bunları analiz etmek için bir mekanizma kurmalıyız"):
```bash
qapu ct-check <device_id>          # visual panel, one box per phase (R/S/T), colored red if flagged
qapu ct-check <device_id> --json
```
Two distinct real installation faults, both diagnosable from `PF_R/S/T` alone (`GET /hermes/data/{device_id}`, no new backend endpoint):
- **Reversed polarity** (a CT's own IN/OUT terminals swapped) - that phase's PF flips *sign* but keeps a magnitude close to the other phases' - the same signal the already-existing (not auto-seeded) `Rule.add_wrong_current_direction_rule()` checks via `AE_R/S/T < 0`, just available every packet instead of waiting for the cumulative meter to go negative.
- **Wrong phase order** (a CT clamped on the wrong line, e.g. R's current physically flowing through what's wired as the "S" channel) - that phase's PF *magnitude* itself looks wrong, since comparing the wrong voltage/current pair mixes in the ~120° phase separation between lines on top of the real load angle.

Both are heuristics, not certainties - flags a phase for physical inspection, doesn't replace it. Detection: baseline = **median** of all three phases' `|PF|` (not "average of the other two" - the first version tried that and broke under a real multi-fault test, since one bad phase polluted the baseline used to judge the *other*, genuinely healthy phases; the median is robust to a single outlier). `deviation = |own |PF|| - baseline|` - `>= 0.15` (`CT_CHECK_DEVIATION_THRESHOLD`) means "FAZ SIRASI ŞÜPHELİ", else a negative sign means "TERS BAĞLI (Polarite)", else "Normal". The 0.15 threshold is a reasonable rule of thumb (a healthy 3-phase load's phases normally track within a few percent of each other), not derived from real fault-case field data - revisit if a real miswiring case doesn't get flagged correctly. Each phase's panel also shows `AE` (cross-referencing the existing rule's own signal) and "son veri: X dk/saat önce" (via the same `_relative_time()` helper used elsewhere). Live-tested against real prod data via deliberately-crafted `PF_R/S/T` packets sent to a disposable test device: healthy (all three ≈0.93-0.94) → all Normal; single reversed-polarity phase → only that phase flagged, others stay Normal; single wrong-phase-order phase → only that phase flagged; **two simultaneous faults** (one reversed + one phase-order, the case that broke the first "average of other two" version) → all three phases correctly classified independently. Device left in a healthy state afterward.

**`qapu pipeline` (added 2026-09-04)** - fleet-wide pipeline throughput for a time window, per direct request ("son x süre içerisinde ingeste kaç paket gelmiş data ne kadar işlemiş rule ne işlemiş gibi... docker servis özetleri"):
```bash
qapu pipeline                    # last 60 minutes (default)
qapu pipeline --minutes 1440     # last 24 hours
qapu pipeline --json
```
Not device-scoped - a fleet-wide summary across every service in the `ingest -> stream -> calibration -> raw-writer -> synthesis -> ... -> rule` pipeline. Deliberately reads from the real tables each stage actually writes to (`raw_data.stream_time`/`valid_pack` for `hardware`'s ingest, `streams.stream_time` for `data`'s calibration+synthesis completion, `device_timeline.create_time` for `rule`'s observable output, `blockchain.create_time` for mined blocks) rather than any service's own `/health` endpoint - those are point-in-time snapshots (uptime, CPU, last-message-age), not a windowed throughput count. New `common/qapu_common/domain/pipeline.py` (`Pipeline_Stats.get_stats(minutes)`, four plain `SELECT count(*) ... WHERE <time column> >= now() - interval` queries, no caching - these are meant to be live) + `GET /hermes/pipeline/stats` (`services/api/src/routers/hermes.py`, `minutes` query param, 1 to 10080/7 days). **`Rule_Events` deliberately does NOT count rule evaluations** - `rule` evaluates every single stream message 1:1, the same count `Data_Processed` already reports, so repeating it would be redundant; it counts `device_timeline` rows instead, rule's actual observable output (a trigger/reset transition), not every evaluation pass. The CLI prints a bordered summary table plus a soft backlog hint (yellow note, not an error) if `Data_Processed` looks meaningfully behind `Ingest_Valid` (`<80%`) - a sign of consumer lag worth checking, not itself proof of one. Live-tested against real prod DB: `--minutes 60` correctly returned all zeros (no test traffic in the preceding hour at that point in the session), `--minutes 1440` correctly returned real 24h counts, and a fresh real packet sent immediately before a `--minutes 5` check showed up as exactly `+1` across Ingest/Data_Processed/Blockchain_Mined (Rule_Events stayed `0`, correctly - that packet's only variable, `PCB_T`, didn't cross any rule threshold).

**Last-packet line added the same day**, per a live debugging session ("son gelen veri paketi device id ve son veri zamanı da realtime yazsın") - a user (Recep) reported not seeing a real device's packet land, which turned into checking `raw_data` by hand each time; now `Pipeline_Stats.get_stats()` also returns the single most recent `raw_data` row fleet-wide (`Last_Device_ID`/`Last_Packet_Time`, not windowed by `--minutes`), and the CLI prints it as `Son paket: <device_id> - Xdk Ysn önce (absolute time)` above the table - recomputed live via `_elapsed_seconds_label()` so it ticks in `--watch` mode too. Live-tested against real prod data: correctly showed the real last-arriving device and an accurate elapsed count.

**`--watch`/`-w` added the same day**, per direct follow-up request ("pipe line canlı takip edilebilecek bir tool yapabilirmiyiz") - polls the same endpoint every `--interval` seconds (default `5.0`) and redraws the summary in place via Rich's `Live`, instead of printing once and exiting; `Ctrl+C` stops cleanly. Each poll after the first also shows a `DEĞİŞİM` column - green `+N` if a stage's count grew since the last poll, red `-N` if it somehow shrank (shouldn't happen for these monotonic counters within a fixed window, but rendered anyway rather than assumed impossible), `·` if unchanged - a bare redrawn count doesn't read as "live" the way a visible delta does. Refactored the table-building logic into a shared `_build_pipeline_group()` (title + table + optional backlog note, returned as one `rich.console.Group`) used by both the one-shot path and the `--watch` loop, so they can't drift apart. Not combinable with `--json` (a live-refreshed raw-JSON dump isn't a coherent thing to build - errors clearly if both are passed). The delta/redraw logic itself was verified directly (calling `_build_pipeline_group()` with two synthetic consecutive polls and confirming the `+4`/`+2`/`·` rendering, plus the empty-result error path) rather than through a full `--watch` session end to end - `rich.Live`'s screen-redraw behavior depends on `console.is_terminal`, which this environment's piped/non-TTY test harness can't exercise the same way a real interactive terminal does; confirm the actual live redraw looks right in a real terminal session if this is touched again.

`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 firmware add <version> [--title] [--description] [--channel] [--active/--inactive] [--file-name] [--file-size] [--file-url] [--md5] [--sha256] [--signature] [--release-notes]   # POST /hermes/firmware
qapu firmware update <version> [--new-version] [...same fields as add]   # PUT /hermes/firmware/{version} - only passed fields change
qapu firmware delete <version>                      # DELETE /hermes/firmware/{version} - catalog row only, not the underlying file
qapu firmware upload <file_path> --version X [--title] [--description] [--channel] [--active/--inactive] [--release-notes] [--ftp-host] [--http-base-url]   # MD5 + FTP upload + catalog add/update, in one step (needs QAPU_FTP_ADMIN_PASSWORD)
```

**Firmware write access + `upload` (added 2026-09-04)** - built to close the gap the MQTT command protocol rebuild's `fotahttp`/`fotaftp` commands left: those commands are fully functional (build/validate/send), but there was nowhere real for a device to actually pull a file *from* - just a `firmwares` catalog table tracking metadata with no file behind it. `Firmware` (`common/qapu_common/domain/firmware.py`) already had full CRUD; only `list`/`get` were wired up here, extended with `add`/`update`/`delete` following the same shape as `manufacturer`/`model`. `upload` is the actual publish flow and does the whole job client-side - the API never touches file bytes: computes the local file's MD5, `ftplib.STOR`s it to the new `deploy/ops/ftp/` server as `cinga_ftp_admin`, then adds/updates the version's catalog row with the real `File_Name`/`File_Size`/`Hash_MD5`/`File_URL`, and prints ready-to-copy `fotahttp`/`fotaftp` command lines. New `client.get_or_none()` (`tools/cli/qapu_cli/client.py`) backs the add-vs-update decision - the existing `client.get()` treats any 404 as fatal (red panel + exit), correct for a real lookup but wrong for an existence check where "not found yet" is the expected, common case.

**Real, pre-existing DB constraint found live while testing `update`, not a bug**: `firmwares` has a check constraint rejecting `Active=true` without file metadata already present - a sensible guard (don't let a version go "active" with nothing real behind it). Confirmed by deliberately triggering it (`--active` alone, no `--file-name`/`--md5`) - correctly rejected; a realistic payload (file metadata + `--active` together, matching what `upload` always sends) succeeds cleanly. Worth knowing: `Firmware.update()`'s error handling only logs when `logger=True`, and this router uses `logger=False` throughout, so a constraint violation like this currently reaches an API caller as an undifferentiated 500 rather than a clear 400 - not fixed here (pre-existing domain code, out of scope for this pass), just flagged.

**FTP is fully live, HTTP still isn't (2026-09-04)** - `deploy/ops/ftp/` (`stilliard/pure-ftpd` on `ops-prod-01`) is deployed and confirmed working **from the real internet with VPN off** (login + passive-mode `LIST` against `176.235.144.197:21` succeeded first try through the new WatchGuard `FTP-FIRMWARE` policy). `upload`'s `--ftp-host` default is the raw public IP (`176.235.144.197`) - not `ftp.ovoo.com.tr`, since that DNS record doesn't exist yet; pass `--ftp-host ftp.ovoo.com.tr` instead once/if it's created. `firmware.ovoo.com.tr` (HTTP hosting, for `fotahttp`) is still not deployed - see `deploy/ops/ftp/README.md`'s Part 2.

**`fotahttp`/`fotaftp` gained `--version` the same day** ("md5 i de yükleyince sistem oluştursun") - instead of copy-pasting the `File_Name`/`Hash_MD5` `upload` just printed, pass `--version X` and the CLI looks them up from the catalog itself (`GET /hermes/firmware/{version}`); explicit `--firmware`/`--md5` still work and take priority if given alongside `--version`. `fotaftp`'s `--user` also now defaults to `cinga_fota` (the standing read-only device account) instead of being required every time. Live-tested end to end: `qapu firmware upload 01.00.24.hex --version 01.00.24` followed by `qapu device-command fotaftp <device_id> --server 176.235.144.197 --password ... --version 01.00.24` - no `--firmware`/`--md5` typed by hand at all, command sent successfully. Also confirmed the public IP is reachable for the *upload* connection even with VPN on (no hairpin-NAT issue in this direction, unlike the documented case of a host on the internal network reaching its own NAT'd public IP) - direct test, not assumed.

```bash
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 fleet status                        # every device, online/offline summary
qapu fleet status --group <group_id>     # only devices in this group
qapu fleet status --project <project_id> # only devices in this project
qapu fleet status --watch                # live-refreshed, updates every --interval seconds (default 10)
qapu fleet status --json
```

**`fleet status` (added 2026-09-04)**, per direct request for a live fleet health view ("cli da canlı device listesini gösteren komut yazalımmı --watch da olsun güzel özetleri olan neler canlı neler ölü gösteren güzel bişi"). Unlike `fleet data` (which requires exactly one of `--group`/`--project`), scoping here is optional - omitting both shows every device, since "is my whole fleet healthy" is a legitimate question on its own, and pulling online/offline status for every device is cheap (unlike `fleet data`'s per-variable fan-out). No new backend endpoint - reuses `GET /hermes/devices` (for the unscoped and `--group` cases, filtered client-side against `GET /hermes/groups/{id}/devices`' bare ID list) and `GET /hermes/projects/{id}/devices` (already returns full device detail) - and the exact same `Update_Time`-based online/offline heuristic `device list --status` already uses (`_is_online()`, `ONLINE_THRESHOLD_MINUTES = 30`), so the two commands never disagree about what "online" means. Output leads with a colored `X/Y çevrimiçi (Z%)` summary line (green if every device is online, yellow if some are, red if none are), then a table sorted offline-first (the more actionable half) - `--watch` live-refreshes via the same `Live` pattern as `pipeline`/`data --watch`, with `SON BAĞLANTI` ticking via `_elapsed_seconds_label()` instead of the coarser `_relative_time()` the one-shot view uses. Live-tested against real prod data: unscoped correctly showed `2/11 çevrimiçi (18%)` (the two devices with genuinely recent traffic that day, `1000000000000003` and `400000011D081B70`, both correctly listed as Online with a fresh "X dk önce"; the other 9 official test devices correctly Offline, several days stale); `--project 1` and `--group 2` ("Test Cihazları") both correctly scoped; `--group`+`--project` together and `--watch`+`--json` together both correctly refused.

```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}
qapu device-command send <device_id> <command> [--mqtt] [--version V] [--user-id] [--auto-retry]   # HTTP (template catalog) or MQTT (start/stop/reboot/restart/force)
qapu device-command settings <device_id> [--on-int] [--off-int] [--stop] [--notf] [--v-scale] [--v0] [--v1] [--vimb] [--cimb] [--p0] [--p1] [--ps] [--del] [--m-sense] [--user-id] [--auto-retry]
qapu device-command fotahttp <device_id> --server --path [--version X | --firmware --md5]        # POST /hermes/iot/mqtt/fotahttp/{device_id} - --version looks up --firmware/--md5 from the catalog
qapu device-command fotaftp <device_id> --server --password [--user cinga_fota] [--version X | --firmware --md5] [--mode]        # POST /hermes/iot/mqtt/fotaftp/{device_id} - --user defaults to cinga_fota
```

**`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` was imported at the time, `IoT_Communication` (which owns `send_command()`/`send_command_mqtt()`, the real device-facing calls) was left untouched. This looked close to the `iot`/`command` boundary this router had 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 wanted 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).

**`device-command send` (added 2026-09-04) deliberately reverses that boundary, per explicit user request** ("kanka tüm komutları çalıştırabilelim cli dan"). What prompted it: testing whether `services/communication_mqtt` (built 2026-08-31, never exercised end to end) actually delivers a command - done first via a standalone script calling `IoT_Communication.send_command_mqtt()` directly against a real, live-connected field device (`400000011D081B70`, a real `Start`/pump command), with an independent MQTT subscriber confirming the exact payload landed on `devices/400000011D081B70/commands` - the full path (Kafka `qapu.communication.mqtt` → `communication_mqtt` → broker → device topic) worked, first time ever proven live. Before building it, three narrower alternatives were offered (Hermes+Start/Stop-only, Hermes+mandatory terminal confirmation, don't add it) - the user picked the unrestricted option. **A leaked `QAPU_HERMES_KEY` can now send a real command to any device** - same trust model already accepted for groups/infrastructure/manufacturers/models/calibration writes on this router, now extended to actuation too, by explicit user choice.

**MQTT command protocol fully rebuilt the same day, right after** - the firmware team (Recep) sent a formal command/ACK contract document, and comparing it against what had just shipped revealed the payload sent above (`{"Request":{"ID":"...","Event":256}}`) doesn't match the real firmware contract at all (`{"id","type","exp","user","source"}`, case-sensitive) - per the doc's own edge cases, the firmware likely silently ignored that earlier live test (no `type` field → no ACK produced). Reviewed the doc fully, reported every gap found (payload mismatch, an `exp` clock-base risk, a 255-byte MQTT buffer limit nothing enforced, no ACK handling at all) before writing any code, then built a full implementation plan (reviewed and approved) once asked to build it to spec ("dokümana uygun yapalım kanka"). `--mqtt` now only supports `start`/`stop`/`reboot`/`restart`/`force` (case-insensitive), routed through a new dedicated `/hermes/iot/mqtt/command/{device_id}/{type}` endpoint - the old `?transport=mqtt` path on the generic send endpoint now rejects MQTT outright with a clear error, rather than leaving the old, wrong payload path reachable. Three new subcommands cover the remaining documented types: `settings` (only the flags you pass are sent, matching the firmware's own "omitted = unchanged" semantics, with real range/cross-field validation - `v0<v1`, `p0<p1`, etc. - matching every documented limit), `fotahttp`/`fotaftp` (FOTA firmware push via HTTP/FTP, exact-length field limits and a 32-hex `md5` enforced). `--auto-retry` (default off) allows exactly one same-`id` retry if no ACK arrives before the command expires - but only ever actually retries `reboot`/`restart`/`settings`/`start`/`stop` (the device's own EEPROM-based duplicate check only protects those 5); `force`/`fotahttp`/`fotaftp` never auto-retry regardless of the flag, since a resend genuinely re-triggers them (no dedup protection on the device side for those three). Live-tested end to end against real prod Redis/Kafka/MQTT (a disposable Kafka consumer group to avoid racing the still-deployed old service; a real ack correlation cycle run against a live local instance of the rebuilt `communication_mqtt`) - all using the safe official test device `1000000000000001`, never the real field device. Two items still need confirming with the firmware side before any real command send once this deploys: which clock base the device's `exp` comparison actually uses (`hardware`'s `/time` deliberately returns UTC+3, not true UTC), and that the broker's `cinga_mqtt` account still has subscribe permission on `devices/+/acks`. See `CLAUDE.md`'s own 2026-09-04 entry for the full technical writeup (byte-budget math, Redis TTL gotcha, retry design, etc.).

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. `finance`/`user` (money/PII) still stay off this router entirely - unlike `iot`/`command`, no request to expose those has come up. See CLAUDE.md's 2026-09-01 and 2026-09-04 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.)
