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:
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:
cd tools/cli
pip install -e .
Either way installs a qapu command (see pyproject.toml's [project.scripts]). This package has no dependency on the rest of the monorepo (qapu_common etc.) - it only ever talks to Qapu over HTTP, never imports it directly - which is exactly what makes the git-subdirectory install above work without cloning anything else.
Configuration
| Env var | Purpose |
|---|---|
QAPU_API_URL |
Base URL. Defaults to https://api.ovoo.com.tr. Point at http://localhost:8000 or an internal IP for local/dev testing. |
QAPU_HERMES_KEY |
Shared-secret value for the X-Hermes-Key header - see "Auth" below. Required for every command except qapu health. |
Commands
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 trend <device_id> <variable_id> # GET /hermes/trend/{device_id}/{variable_id} - last 20 raw readings + trend
qapu trend <device_id> <variable_id> --last 50 # last N raw readings instead of the default 20
qapu trend <device_id> <variable_id> --days 2 # daily min/avg/max over the last 2 days, instead of raw readings
qapu trend <device_id> <variable_id> --days 30 # ...or the last month
qapu trend <device_id> <variable_id> --start 2026-08-30T14:00:00 --end 2026-08-30T18:00:00 # raw readings in an explicit window (e.g. around an incident)
qapu trend <device_id> <variable_id> --start 2026-08-01 --end 2026-08-31 --days 5 # daily min/avg/max narrowed to that range
qapu trend <device_id> <variable_id> --json
qapu trend shows a per-variable history plus a simple linear-regression trend line (slope + direction). Three modes, picked by which options you pass:
- Raw readings (
--last N, defaultN=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}/historyendpoint reads - this is what actually covers multi-day/month windows, since the raw buffer doesn't go back that far.--dayswins over--lastif both are given (and--start/--endisn'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--dayswas 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/--endare 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.
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.
qapu manufacturer list [--json] [--limit N] # GET /hermes/manufacturers - grouped by ID thousand (3xxx = "Electric Equipment", etc.), ID-ordered
qapu manufacturer get <manufacturer_id> [--json] # GET /hermes/manufacturers/{id}
qapu manufacturer add "Name" --category <electronics|modem|electric-equipment|transformator|pump> [--description] # POST /hermes/manufacturers
qapu manufacturer update <manufacturer_id> [--name] [--description] # PUT /hermes/manufacturers/{id} - only passed fields change
qapu manufacturer delete <manufacturer_id> # DELETE /hermes/manufacturers/{id}
qapu model list [--json] [--limit N] # GET /hermes/models - same thousand-grouping as manufacturer, plus a MANUFACTURER column
qapu model get <model_id> [--json] # GET /hermes/models/{id}
qapu model add "Name" --manufacturer-id <id> [--description] # POST /hermes/models - manufacturer also determines its category
qapu model update <model_id> [--name] [--description] [--manufacturer-id <id>] # PUT /hermes/models/{id} - only passed fields change; changing manufacturer relocates the ID if the category differs (see below)
qapu model delete <model_id> # DELETE /hermes/models/{id}
qapu modem list [--manufacturer-id] [--model-id] [--status-id] [--json] [--limit N] # GET /hermes/modems
qapu modem get <imei> [--json] # GET /hermes/modems/{imei}
qapu sim list [--operator-id] [--active/--inactive] [--online/--offline] [--json] [--limit N] # GET /hermes/sims
qapu sim get <iccid> [--json] # GET /hermes/sims/{iccid}
qapu firmware list [--active/--inactive] [--channel] [--json] [--limit N] # GET /hermes/firmware
qapu firmware get <version> [--json] # GET /hermes/firmware/{version}
qapu status list [--json] [--limit N] # GET /hermes/statuses
qapu status get <status_id> [--json] # GET /hermes/statuses/{id}
qapu status add "Name" [--id N] [--description] # POST /hermes/statuses - pass --id to land in a specific entity-kind range (1xx=device, 2xx=..., see `status list`)
qapu status update <status_id> [--name] [--description] # PUT /hermes/statuses/{id} - only passed fields change
qapu status delete <status_id> # DELETE /hermes/statuses/{id}
qapu timeline <device_id> [--unread] [--page N] [--size N] [--json] # GET /hermes/timeline/{device_id}
qapu blockchain list <device_id> [--json] # GET /hermes/blockchain/{device_id} - every mined block, genesis to latest
qapu blockchain get <device_id> <index> # GET /hermes/blockchain/{device_id}/{index} - one block's full payload
qapu blockchain validate <device_id> [--json] # GET /hermes/blockchain/{device_id}/validate - length, secure/tampered status, time range
qapu calibration list <device_id> [--json] # GET /hermes/calibration/{device_id} - Gain/Offset per variable
qapu calibration get <calibration_id> [--json] # GET /hermes/calibration/detail/{calibration_id}
qapu calibration add <device_id> <variable_id> --gain <g> --offset <o> [--description] # POST /hermes/calibration
qapu calibration update <calibration_id> [--gain] [--offset] [--description] # PUT /hermes/calibration/{calibration_id} - only passed fields change
qapu calibration delete <calibration_id> # DELETE /hermes/calibration/{calibration_id}
qapu crop-type list [--json] [--limit N] # GET /hermes/crop_types
qapu crop-type get <id> [--json] # GET /hermes/crop_types/{id}
qapu crop-type add "Wheat" --description "..." # POST /hermes/crop_types - a new kind is just a new row
qapu crop-type update <id> [--name] [--description] # PUT /hermes/crop_types/{id} - only passed fields change
qapu crop-type delete <id> # DELETE /hermes/crop_types/{id}
qapu irrigation-type list [--json] [--limit N] # GET /hermes/irrigation_types
qapu irrigation-type get <id> [--json] # GET /hermes/irrigation_types/{id}
qapu irrigation-type add "Drip" --description "..." # POST /hermes/irrigation_types - a new kind is just a new row
qapu irrigation-type update <id> [--name] [--description] # PUT /hermes/irrigation_types/{id} - only passed fields change
qapu irrigation-type delete <id> # DELETE /hermes/irrigation_types/{id}
qapu setting get <device_id> [--json] # GET /hermes/setting/{device_id} - thresholds, register, electric box
qapu setting update <device_id> [--stop] [--publish] [--ct-ratio] [--auto-start-delay] [--temp-min] [--temp-max] [--voltage-min] [--voltage-max] [--current-max] [--frequency-min] [--frequency-max] [--voltage-imbalance-max] [--current-imbalance-max] [--pressure-min] [--pressure-max] [--pressure-slope-min] [--pressure-slope-max]
qapu stream list <device_id> [--page N] [--size N] [--json] # GET /hermes/stream/{device_id} - ingested packet history, most recent first
qapu stream get <stream_id> # GET /hermes/stream/detail/{stream_id}
qapu synthesis list [--device-id] [--variable-id] [--active/--inactive] [--json] # GET /hermes/synthesis - derived-metric calculation rules
qapu synthesis get <rule_id> [--json] # GET /hermes/synthesis/{rule_id}
qapu synthesis add <variable_id> --required-variables A,B,C --calculation '{"formula": "A + B"}' [--device-id] [--conditions] [--min-value] [--max-value] [--priority] [--active/--inactive] [--description] # POST /hermes/synthesis
qapu synthesis update <rule_id> [--variable-id] [--required-variables] [--calculation] [--device-id] [--conditions] [--min-value] [--max-value] [--priority] [--active/--inactive] [--description] # PUT /hermes/synthesis/{rule_id}
qapu synthesis delete <rule_id> # DELETE /hermes/synthesis/{rule_id}
qapu user-role list [--json] [--limit N] # GET /hermes/user_roles - e.g. admin/farmer/technician, NOT the User class itself (PII, deliberately excluded)
qapu user-role get <role_id> [--json] # GET /hermes/user_roles/{id}
qapu user-role add <id> "Name" [--description] # POST /hermes/user_roles - ID is REQUIRED, this catalog's ID is NOT auto-increment
qapu user-role update <role_id> [--name] [--description] # PUT /hermes/user_roles/{id} - only passed fields change
qapu user-role delete <role_id> # DELETE /hermes/user_roles/{id}
qapu irrigation list <device_id> [--json] # GET /hermes/irrigation/{device_id} - event history (start/end/duration)
qapu irrigation abstract <device_id> [--json] # GET /hermes/irrigation/{device_id}/abstract - last irrigation + 24h/today/30d/month totals
qapu project list [--json] [--active/--inactive] [--limit N] # GET /hermes/projects
qapu project get <project_id> [--json] # GET /hermes/projects/{project_id}
qapu project devices <project_id> [--json] [--limit N] # GET /hermes/projects/{project_id}/devices - full device detail, not just IDs
qapu project add "Name" [--description] # POST /hermes/projects
qapu project update <project_id> [--name] [--description] [--active/--inactive] # PUT /hermes/projects/{project_id}
qapu project delete <project_id> # DELETE /hermes/projects/{project_id}
synthesis extended to full CRUD (2026-09-03), per direct user correction after the read-only version shipped the day before ("qapu synthesis için tam crud yapmamışsın sadece list var"). The 2026-09-02 read-only version was a faithful reflection of the domain class at the time - Synthesized_Metrics genuinely had no add()/update()/delete(), only get_list(). Added detail()/add()/update()/delete() to common/qapu_common/domain/synthesized_metrics.py (following the same partial-update/cache-invalidation pattern every other domain class in this session uses), then wired all four into services/api/src/routers/hermes.py and the CLI. --required-variables/--conditions take comma-separated/JSON-string input respectively (Required_Variables is a plain list, Conditions/Calculation are JSON columns) - --calculation/--conditions are validated as JSON client-side before the request goes out, with a clear error instead of a confusing 400 from the API. New unit tests in common/tests/test_synthesized_metrics.py (detail/add/update/delete, 10 new cases) - 502 tests passing.
Two real production findings surfaced while live-testing this, neither a bug in the new code, both since resolved (see CLAUDE.md's 2026-09-03 entries for the full incident/recovery detail):
synthesis_variable_rulesturned out to be completely empty in production at the time -qapu synthesis listhad 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 sincesynthesize_measurements()(common/qapu_common/domain/measurement.py, called on every real device packet byservices/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 viaSynthesized_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_IMBdepends onPF_AVGbut was ID-ordered before it in the legacy dump, so it was silently skipped every evaluation; fixed by re-insertingPF_AVGbeforePF_IMBso ID order matches the dependency. All 9 rules verified correct via a realcalculate_synthesized_metrics()run.- A "global" rule (
Device_ID="0", applied to every device) could not be added at all -synthesis_variable_rules.device_idis a foreign key intodevices.id, and there was noid='0'placeholder row indevices(unlikeprojects/statuses, which do have a realid=0"Unknown" row). Fixed same day, per explicit user request: addeddevices.id='0'("Unknown Device",Status_ID/Project_IDboth0, both already real "Unknown" rows) viaDevice().add();Device.list()(common/qapu_common/domain/device.py) updated to excludeid="0"(same exclusionProject.list()already applies to its own Unknown row) so this placeholder never appears inqapu device list/GET /hermes/devicesas 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.
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).
qapu device-command list [--json] [--limit N] # GET /hermes/device_commands
qapu device-command get <command_id> [--json] # GET /hermes/device_commands/{id} - includes the full payload Template
qapu device-command add "Start" --end-point / --template '{"ID":"%ID%"}' --timeout 30 [--description] # POST /hermes/device_commands
qapu device-command update <command_id> [--command] [--end-point] [--template] [--timeout] [--description] # PUT /hermes/device_commands/{id}
qapu device-command delete <command_id> # DELETE /hermes/device_commands/{id}
device-command (added 2026-09-03) is full CRUD, per explicit user confirmation after a direct security check - this is the command TEMPLATE catalog (common/qapu_common/domain/iot.py's IoT_Command class, wrapping the device_commands table: Command/End_Point/Template/Time_Out/Description), which IoT_Communication.send_command() reads from to know what to actually send a device - not the actuation itself. IoT_Command and IoT_Communication are two completely separate classes in the same file; only IoT_Command is imported here, IoT_Communication (which owns send_command()/send_command_mqtt(), the real device-facing calls) is never touched. This looks close to the iot/command boundary this router has held firm on all session ("a leaked QAPU_HERMES_KEY should never be able to control a physical device") - flagged explicitly to the user before writing any code, since a template's End_Point/Template fields do describe the device-facing protocol; the user confirmed they specifically want the template catalog manageable via CLI, not device actuation itself, and chose full CRUD (not read-only) knowingly. Live-tested against real prod data: device-command list showed the 6 real templates (Start/Stop/Update/Setting/Old_Update/Firmware) untouched; device-command get 1 showed Start's real Template JSON; a full add→get(confirm)→update(confirm)→delete→get(404) cycle on a disposable test command, none of the 6 real templates ever modified. 492 tests still passing (no domain-layer changes - IoT_Command already existed and was already correct).
These six catalogs (manufacturer/model/modem/sim/firmware/status) plus timeline follow the exact same read-only list/get shape as variable - low-sensitivity reference/inventory data, safe to expose behind the Hermes shared-secret like everything else in this router. Deliberately not extended to every domain module - anything that can actuate a real device (iot/command - sending pump start/stop, settings pushes) or touches money/PII (finance/user) stays off this router entirely; a leaked QAPU_HERMES_KEY should never be able to control a physical device or read financial/personal data. See CLAUDE.md's 2026-09-01 entries for the full reasoning if this list needs revisiting.
manufacturer/model are a third write exception alongside group/infrastructure/equipment_type (added 2026-09-01) - same low-sensitivity reasoning: plain catalog rows, no device actuation, no financial/PII exposure. manufacturer list/model list also group their output by the catalog's ID-hundreds-as-category convention (e.g. 301-399 = "Electric Equipment") instead of a flat table: manufacturers carry an actual round-hundred row per category (Name is the category name, e.g. (300, "Electric Equipment")), so those header rows are looked up and excluded from their own member list; models carry no such header rows, so model list looks category names up from the manufacturer catalog using the same numeric convention. A category with zero members (e.g. 400-499 "Transformator", currently empty) is simply not printed; --json bypasses grouping entirely and returns the flat list as before.
blockchain (added 2026-09-02) is read-only, same reasoning as the seven catalogs above - a device's mined block history and chain-integrity status are low-sensitivity trust/observability data, no actuation, no financial/PII exposure. Mining/writing blocks stays exclusively services/blockchain's job; nothing here calls mine_block()/save_block()/delete_by_device(). blockchain list shows a summary table (index/proof code/truncated previous hash/created time) since the full block payload (device connection/environment/energy snapshot at mining time) is large - use blockchain get <device_id> <index> for one block's full JSON. blockchain validate re-runs the same integrity check Blockchain.validate_chain() already does internally (genesis block shape, sequential indexes, previous-hash linkage, proof-of-work difficulty) and reports chain length/secure-or-not/time range - live-tested against a real device's 56-block chain, confirmed secure.
calibration (added 2026-09-02) is full read/write, per explicit user request ("kalibrasyonu da yapalım... domaindeki gibi add-delete-update vs olsun") - simple, single-resource commands (list/get/add/update/delete), not the flag-based consolidation infrastructure uses, since calibration has no sub-resource types to disambiguate. Deliberately wider than the real /calibration API router (services/api/src/routers/calibration.py), which stays read-only on purpose per that file's own header comment (a legacy behavior being preserved there, not a security judgment) - Hermes's version is a separate, additional surface, not a change to that router. Same low-sensitivity reasoning as group/infrastructure/manufacturer/model: a Gain/Offset scaling factor per device/variable, no device actuation, no financial/PII exposure. A live write through this endpoint takes effect immediately in the pipeline (goes through Calibration.add()/.update(), so the calibration:list Redis cache is correctly invalidated - no manual cache-bust needed here, unlike a raw-SQL migration). Live-tested a full add→get→update→get(confirm)→delete→get(404) cycle via the CLI against a local API instance, using a disposable test row on a low-traffic official test device - the real CT_RATIO/VT_RATIO rows already live on production devices were only ever read (calibration list), never touched.
Real bug found and fixed the same day, right after the user actually used --description in production: --description on add/update silently no-opped - calibrations never had a description column at all (Schema.Calibration_Schema.Description existed in the API contract, but Calibration.add()/.update() never read or wrote it, confirmed via information_schema.columns). The Description the CLI was already showing (always -) came from a completely different field - calibration_view's join to variables.description (the variable's own description, e.g. "R Phase RMS Voltage"), not a calibration-level note. Fixed via common/migrations/2026-09-02_add_calibration_description.py - adds a real, nullable calibrations.description column and appends a new calibration_description column to calibration_view (Postgres only allows CREATE OR REPLACE VIEW to add columns at the end, so the existing description/variable-description column stays untouched at its original position). common/qapu_common/domain/calibration.py's list()/add()/update() updated to read/write the new column; Calibration_Schema.Description now means what it always looked like it meant. Live-tested end to end against real prod data, including the exact commands the user had run that silently failed - calibration update 1889/1892 --description "..." now actually persists (confirmed via calibration list showing the real text and a bumped Update_Time, previously unchanged since 2026-08-30).
crop-type (added 2026-09-02) follows equipment-type/pump-type's exact simple catalog shape - Crop_Type (common/qapu_common/domain/crop_type.py) is a plain ID/Name/Description catalog (what a land plot grows), full CRUD wired up the same way. Live-tested against real prod data: 46 real crop types (Buğday/Arpa/Mısır/... through fruit trees and berries) plus a full add→get→update→get(confirm)→delete→get(404) cycle on a disposable test row.
irrigation-type (added 2026-09-02) follows the same simple catalog shape - Irrigation_Type (common/qapu_common/domain/irrigation_type.py) is a plain ID/Name/Description catalog (how a land plot is irrigated - Drip/Sprinkler/...), full CRUD wired up identically to crop-type. Live-tested against real prod data: 5 real irrigation types (Damla/Yağmurlama/Vahşi/Pivot/Yeraltı Damla Sulama) plus a full add→get→update→get(confirm)→delete→get(404) cycle on a disposable test row. irrigation (the actual irrigation-event records, as opposed to this type catalog) is a separate, much larger domain class with no add/update/delete at all - events are derived automatically from a device's own register history by the pipeline, not entered by hand; see CLAUDE.md's entry on this if/when that one gets wired up.
setting (added 2026-09-02) is device-scoped read/write, not a catalog - qapu setting get <device_id>/update mirror the real /setting/{device_id} API router's own two endpoints exactly (Device_Setting.detail()/.update()), just under the Hermes shared-secret. Covers rule thresholds (8 sections, each a Min/Max pair - Temperature/Voltage/Current/Frequency/Voltage_Imbalance/Current_Imbalance/Pressure/Pressure_Slope), the register Stop/Publish bitmasks, CT ratio, and electric box auto-start delay - configuration values, no device actuation, no financial/PII exposure, same reasoning as calibration/infrastructure. update only sends the fields actually passed (each threshold section is Min/Max independently optional) and refuses with a clear error if called with nothing to change. Live-tested against real prod data: setting get correctly showed a real device's actual thresholds/register/CT ratio (including the Register.Status fix right below this); a full round-trip update (same values back) on a low-traffic test device confirmed the write path (rule value_update, calibration update) works end to end without changing anything.
Real bug found and fixed the same day, caught by the user just reading the file in the IDE: Device_Setting._register()'s SQL query never selected register_status - Register.Status had been hardcoded None on every GET /setting/{device_id} response, while the other place building a Register_Schema (Device.get_and_cache_device(), for /device/{id}) read it correctly the whole time. Fixed - see CLAUDE.md's entry for the live-verification details.
status gained add/update/delete (added 2026-09-02), extending what had been list/get-only since 2026-09-01. Status.add() accepts an explicit ID (unlike the manufacturer/model category scheme, there's no automatic range computation here) - status add "Name" --id 305 lands it at a specific ID if the caller wants to keep this catalog's existing entity-kind-by-hundred convention (status list shows real examples: 100s = 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 explicitID- unlikeStatus(which falls back to auto-increment whenIDis omitted),user_roles.idis not an auto-increment column at all (Column(Integer, primary_key=True, nullable=False), noautoincrement). The first CLI version only tookName/--description, so everyaddfailed with a misleading"User role Name is required"(Name was provided -IDwas the actual missing field). Fixed by makingida required positional argument onuser-role addand 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 ID999)→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 anActivefield that doesn't exist onIrrigation_Schemaat all (onlyLast_Irrigation_Schema, used byabstract, 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 noEnd_Timeyet 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: 1011min) and one correctly marked ongoing after the fix;abstractshowed 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.
qapu equipment-type list [--json] [--limit N] # GET /hermes/equipment_types - CT/contactor/breaker/VFD/... catalog
qapu equipment-type get <id> [--json] # GET /hermes/equipment_types/{id}
qapu equipment-type add "VFD" --description "..." # POST /hermes/equipment_types - a new kind is just a new row
qapu pump-type list [--json] [--limit N] # GET /hermes/pump_types - submersible/centrifugal/... catalog
qapu pump-type get <id> [--json] # GET /hermes/pump_types/{id}
qapu pump-type add "Submersible" --description "..." # POST /hermes/pump_types - a new kind is just a new row
qapu pump-type update <id> [--name] [--description] # PUT /hermes/pump_types/{id} - only passed fields change
qapu pump-type delete <id> # DELETE /hermes/pump_types/{id}
qapu infrastructure get <device_id> [--json] # GET /hermes/infrastructure/{device_id} - tree view
# add/update take exactly one resource-type flag instead of a separate verb per
# resource (add-transformer, add-electric-box, ... consolidated 2026-09-02, see below)
qapu infrastructure add --transformer [--capacity] [--manufacturer-id] # POST .../transformer, prints ID
qapu infrastructure add --electric-box --transformer-id <id> [--auto-start-delay] # POST .../electric_box, prints ID
qapu infrastructure add --equipment --box-id <id> --type-id <id> [--manufacturer-id] [--model-id] [--specs '{...}'] [--notes] # POST .../electric_box/{box_id}/equipment, prints ID
qapu infrastructure add --pump --box-id <id> [--power] [--flow-rate] [--pipe-diameter] [--type-id] [--manufacturer-id] # POST .../pump, prints ID
qapu infrastructure add --land-plot --device-id <id> --irrigation-type-id <id> --crop-type-id <id> [--parcel] [--area] [--neighborhood-id] # POST .../{device_id}/land_plot
qapu infrastructure assign <device_id> --pump <pump_id> # PUT .../{device_id}/pump/{pump_id}
qapu infrastructure assign <device_id> --transformer <transformer_id> # PUT .../{device_id}/transformer/{transformer_id}
qapu infrastructure delete <device_id> # DELETE .../{device_id} - unlink + delete the whole chain
qapu infrastructure delete <device_id> --equipment <id> # DELETE .../{device_id}/electric_box/equipment/{id} - just that one item
qapu infrastructure update <device_id> --transformer [--capacity] [--manufacturer-id]
qapu infrastructure update <device_id> --electric-box [--auto-start-delay]
qapu infrastructure update <device_id> --pump [--power] [--flow-rate] [--pipe-diameter] [--type-id] [--manufacturer-id]
qapu infrastructure update <device_id> --equipment --equipment-id <id> [--type-id] [--manufacturer-id] [--model-id] [--specs '{...}'] [--notes]
qapu infrastructure update <device_id> --land-plot --land-plot-id <id> [--irrigation-type-id] [--crop-type-id] [--parcel] [--area] [--neighborhood-id]
Shows/manages a device's electrical infrastructure - either the pump it controls (with its electric box, transformer, installed equipment, and any land plot(s) it irrigates) or the transformer it monitors directly (an OG/medium-voltage device, no pump/box). A second deliberate write exception alongside group (added the same day, after reconsidering) - the standalone transformer/electric-box/pump creation don't even exist on the real require_device_owner-gated /infrastructure API, since "create a transformer not yet linked to anyone" doesn't fit a device-owner permission model anyway; the rest (assignment/updates/land-plot/equipment CRUD) mirror that real API's shape 1:1, just under the Hermes shared-secret instead. Chosen specifically so the team (or an AI agent, given a natural-language description of a panel/pump/transformer) can record real infrastructure data through the CLI without needing a JWT login flow that doesn't exist yet.
add/update/assign/delete were consolidated from 15 separate add-*/update-*/assign-*/delete-* commands down to these 4 (2026-09-02), per explicit user feedback that the original shape ("add-electric-box", "update-pump", "assign-transformer", ...) had gotten confusing - fifteen verb-per-resource commands under one subcommand group is a lot to remember. Each of the 4 now takes exactly one resource-type flag (--transformer/--electric-box/--equipment/--pump/--land-plot for add/update; --pump/--transformer for assign; --equipment optional on delete, its absence meaning "the whole chain" - confirmed with the user rather than assumed) instead of a different verb per resource - refuses with a clear error if zero or more than one flag is given. This makes the resource type the thing that varies, not the command name, which is both fewer top-level commands to discover and closer to how someone would describe the action out loud ("add a transformer", not "add-transformer"). Live-tested end to end via a full build-up-then-teardown chain against a local API instance (transformer → electric box → equipment → pump → assign to a device → land plot → get tree confirms all of it → update every resource type → delete the equipment → delete the whole chain → get confirms empty again), plus the "pick exactly one flag" validation on both add (zero flags, and two flags at once) and assign (both --pump and --transformer together).
A typical build-up sequence: add-transformer → add-electric-box --transformer-id <id> → add-equipment <box_id> --type-id <id> (repeat per item) → add-pump --box-id <id> → assign-pump <device_id> <pump_id> → add-land-plot <device_id> --irrigation-type-id <id> --crop-type-id <id>. Every add-* command prints the ID of what it just created - needed for the next step in the chain.
qapu health
Rewrote health (2026-09-02) - it used to just dump the raw GET /health JSON blob unconditionally, even without --json (caught live: "health kısmı json görünüyor ben json istemeden"). Now shows, in plain readable text: the CLI's own installed version, whether a newer qapu-cli is available on PyPI (checked via https://pypi.org/pypi/qapu-cli/json, best-effort - a slow/unreachable PyPI just silently skips the notice, never blocks the health check itself) with the exact upgrade command to run, the configured API URL, connectivity with round-trip time, and the server's own status (version/hostname/IP/uptime/CPU/memory) - not just the raw JSON. --json still returns the full structured payload (CLI_Version/Latest_Version/Update_Available/API_URL/Connected/Response_Time_MS/Server/Error) for scripting. Exit code 1 on a failed connection, unchanged from before. Live-tested against the real production API (api.ovoo.com.tr, no VPN needed - /health has no auth): correctly showed a real update notice (local dev install at 0.1.0 vs. PyPI's 0.5.0), real server stats, and a real connection-refused error case with the correct exit code.
--json on get commands
Every simple <noun> get command (manufacturer/model/modem/sim/firmware/status/equipment-type/pump-type/calibration/crop-type/irrigation-type/user-role/blockchain/stream) supports --json for raw JSON output; without it, the result prints as readable Key: Value lines (nested objects indented one level per level, via a shared _print_detail() helper) - matching how every list command already behaves. Fixed 2026-09-02 - most of these had as_json: bool = typer.Option(False, "--json") declared but never actually checked (always printed raw JSON regardless of the flag), and blockchain get/stream get didn't even declare the flag at all. Caught live by the user running qapu stream get <id> without --json and getting JSON back anyway.
Auth (current placeholder - read this before pointing at production)
api.ovoo.com.tr is genuinely public on the internet. The Hermes endpoints (services/api/src/routers/hermes.py) are gated by require_hermes_key (services/api/src/dependencies.py) - a single shared-secret string compared against the X-Hermes-Key header, checked via the HERMES_SHARED_SECRET env var on the API side. This is deliberately temporary: it exists only so the CLI/API plumbing could be built and tested end-to-end before the real auth design was ready, not because a shared secret is considered good enough long-term.
Real plan (not built yet - phase 2, along with the score/comment table Hermes will eventually write to): an admin-role hermes-qapu account in the users table, with the CLI gaining a qapu login command that authenticates through the existing JWT_Auth flow every other Qapu client already uses, storing a short-lived token instead of a static shared secret. client.py is written so only it needs to change when that lands - nothing in main.py should need to know how auth works under the hood.
Until then: HERMES_SHARED_SECRET fails closed (unset = every Hermes request rejected, never silently open), but a leaked shared-secret string is a much blunter credential than a scoped, revocable JWT - don't treat this as production-grade access control.
Running locally
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.)
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file qapu_cli-0.6.4.tar.gz.
File metadata
- Download URL: qapu_cli-0.6.4.tar.gz
- Upload date:
- Size: 97.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e801e7f9818bdd01a687d56df722a42f8a9ed40ba497e3c2c4bf33cd2e4793f9
|
|
| MD5 |
e24e6f7349324f172d9f7cf10164ed1e
|
|
| BLAKE2b-256 |
85fe8f0f28eecbbe745cee1e4b66bb9879c8061db5955e21a7dcc389304d6771
|
File details
Details for the file qapu_cli-0.6.4-py3-none-any.whl.
File metadata
- Download URL: qapu_cli-0.6.4-py3-none-any.whl
- Upload date:
- Size: 53.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7dbacd3758841a7023673b9e50ba64fbc04c3722c81f4fd533e01c5d7e494083
|
|
| MD5 |
8dbcc20cb9be4067c2088fe719053abe
|
|
| BLAKE2b-256 |
d20fb0a3f6236a37c9a1486556080047304e82f241a959dc25758808bc51c3db
|