Metadata-Version: 2.4
Name: agrowell-ikh-client
Version: 0.1.1
Summary: Python SDK bridging the I Know How (IKH) robot with the AGRO-WELL greenhouse platform.
Project-URL: Homepage, https://up2metric.com
Project-URL: Repository, https://bitbucket.org/up2metricPC/agro-well-ikh-client
Project-URL: Issues, https://bitbucket.org/up2metricPC/agro-well-ikh-client/issues
Author-email: "up2metric P.C." <info@up2metric.com>
License: BSD-3-Clause
License-File: LICENSE
Keywords: agro-well,apriltag,keycloak,object-placement,robotics,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: fpdf2<3,>=2.8
Requires-Dist: httpx<0.29,>=0.27
Requires-Dist: pydantic-settings<3,>=2.3
Requires-Dist: pydantic<3,>=2.7
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: minio
Requires-Dist: minio>=7.2; extra == 'minio'
Description-Content-Type: text/markdown

# agrowell-ikh-client

A small, typed Python SDK that bridges the **I Know How (IKH)** robot with the
**AGRO-WELL** greenhouse platform.

**v1 scope:**

1. **Authenticate** to the platform via Keycloak (client-credentials / machine-to-machine).
2. **Read anchors** — resolve a detected AprilTag to its platform anchor (and its AR pose).
3. **Commission (align)** — from a reference anchor, compute the anchor-group transform that
   maps the robot's **ROS** measurements onto the platform **AR** scene, write it, and emit a
   quantitative **validation report**. The ROS ↔ Three.js conversion happens **internally**.

> **Status:** v1 boilerplate. Some platform-side prerequisites must be confirmed before
> live integration — see Open items. Open-source under the BSD 3-Clause License.

## Installation

Released under the BSD 3-Clause License. Install from PyPI:

```bash
pip install agrowell-ikh-client
```

Or from a built wheel:

```bash
pip install ./agrowell_ikh_client-<version>-py3-none-any.whl
```

Requires Python 3.10+.

## Configuration

Read from `AGROWELL_`-prefixed environment variables (and an optional `.env` file), or
passed explicitly via `Settings`. Copy `.env.example` to `.env` and fill
in the values from the AGRO-WELL platform team.

| Variable | Required | Default | Description |
|---|---|---|---|
| `AGROWELL_KEYCLOAK_BASE_PATH` | ✅ | — | Keycloak base URL |
| `AGROWELL_KEYCLOAK_REALM` | | `AGRO-WELL` | Keycloak realm |
| `AGROWELL_KEYCLOAK_CLIENT_ID` | ✅ | — | Service-account client id |
| `AGROWELL_KEYCLOAK_CLIENT_SECRET` | ✅ | — | Service-account client secret |
| `AGROWELL_API_BASE_URL` | ✅ | — | object-placement REST base URL |
| `AGROWELL_API_PATH_PREFIX` | | `/v1` | API path prefix (`/v1` or `/api/v1`) |
| `AGROWELL_ORGANIZATION_ID` | ✅ | — | Organization the robot belongs to (scopes all anchor reads/writes) |
| `AGROWELL_FACILITY_ID` | ✅ | — | Facility the robot is installed at (== platform scene id; scopes every read to that facility) |
| `AGROWELL_DEV_MODE` | | `false` | Collect a validation report during commissioning and emit it on `close()` |
| `AGROWELL_VERIFY_SSL` | | `true` | TLS certificate verification |
| `AGROWELL_HTTP_TIMEOUT_SECONDS` | | `10.0` | Request timeout |
| `AGROWELL_HTTP_MAX_RETRIES` | | `3` | Retry budget for idempotent requests |

## Quickstart

```python
from agrowell_ikh_client import AgroWellClient, ScannedTag

with AgroWellClient.from_env() as client:
    # Discover the anchors visible to your organization (raw platform models, AR frame):
    anchors = client.api.anchors.list()

    # Commission: on each AprilTag detection, pass the robot's measured pose (ROS frame).
    # The SDK resolves the anchor, computes the anchor-group transform, and writes it.
    for detection in detections:                  # your detector's per-frame loop
        client.alignment.update_group(
            ScannedTag(
                apriltag_id=detection.id,
                translation=(1.20, 0.0, 3.45),    # metres, ROS frame
                quaternion=(0.0, 0.0, 0.0, 1.0),  # (x, y, z, w)
            )
        )
```

The robot needs only its **organization** (from config) and the **AprilTag ids** it scans.
It never handles anchor UUIDs, anchors-groups, scenes, or raw 4×4 matrices — those are
resolved or converted internally. Poses cross the SDK boundary in the **ROS** frame; the
raw reads under `client.api.*` return platform models in the **AR (Three.js)** frame.

## The rigid-isometry property (why the between-anchor check works)

The internal ROS → Three.js conversion is a **rigid isometry** — a pure rotation of axes
(`det = +1`, no scale or handedness flip), so distances and angles between anchors are
preserved. The commissioning report exploits this: in the relative transform between two
anchors the anchor-group transform `G` cancels, so a residual there can only come from the
ROS ↔ AR conversion itself (a handedness/axis flip or a metre/centimetre mix-up), not from a
single mis-placed anchor. That is what makes the between-anchor check a clean, objective
signal even though there is no visual validation. (See `tests/test_simulation.py`.)

## Commissioning (alignment + validation)

After the 3 anchors are AR-calibrated in the web app, the robot aligns its ROS frame to the
scene by writing the **anchor-group transform**. On **each** AprilTag detection it calls
`update_group`, which resolves the anchor's AR pose, computes the group transform `G` from
that AR pose and the detected ROS pose (`G = T_ar ∘ inverse(convert(T_ros))`), and PATCHes the
group. This runs many times during a commissioning session.

Because updating that transform moves the whole scene subtree together, there is **no visual
validation**. So, with `AGROWELL_DEV_MODE=true`, the client collects each anchor's server
("before") and ROS-computed ("after") pose and emits one **validation report** on `close()`.

```python
from agrowell_ikh_client import AgroWellClient, ScannedTag, ObjectStoreSink

# AGROWELL_DEV_MODE=true enables the report; the sink is where it is uploaded.
# Sinks: ObjectStoreSink (presigned URL), MinioSink.from_settings(settings) (direct MinIO/S3,
# needs the 'minio' extra), or LocalFileSink (disk).
with AgroWellClient.builder().with_report_sink(
    ObjectStoreSink(presign=mint_upload_url)  # mint_upload_url(key) -> presigned PUT URL
).build() as client:
    for tag in detections:                     # the robot's per-detection loop
        client.alignment.update_group(
            ScannedTag(tag.id, tag.translation, tag.quaternion)  # ROS frame
        )
    # on exit: one report (per-anchor errors + between-anchor conversion check) is uploaded
```

The report (`ValidationReport`, render with `report.to_text()`) carries per-anchor
position/orientation errors and aggregates, **plus a pairwise (between-anchor) check that the
ROS → AR conversion holds** — in the relative transform between two anchors `G` cancels, so it
isolates conversion errors (handedness/axis flips, metre/centimetre mix-ups) from a single
mis-placed anchor. Try it offline: `python examples/simulate_commissioning.py`.

## Usage

```python
# Raw request layer, grouped by resource category (client.api.<category>).

# List the anchors visible to your organization (raw platform models, AR frame).
# No group/scene needed; the org context scopes the result.
anchors = client.api.anchors.list()                  # or .list(registered=False)

# Resolve a single detected tag to its anchor. Raises AnchorNotFoundError if none match,
# AmbiguousAnchorError if more than one does:
anchor = client.api.anchors.resolve_by_tag(42)
if anchor.image_anchor and anchor.image_anchor.transform:
    x, y, z = anchor.image_anchor.transform.translation   # AR (Three.js) frame

# Commission: per detection, compute + write the anchor-group transform from a ROS pose.
client.alignment.update_group(
    ScannedTag(42, translation=(1.2, 0.0, 3.45), quaternion=(0.0, 0.0, 0.0, 1.0))
)
```

### Coordinate frames (internal)

The robot speaks only **ROS** (REP-103: X-forward, Y-left, Z-up, right-handed, metres,
quaternions). The platform stores transforms in the **Three.js** AR frame (right-handed,
Y-up). The SDK converts at the boundary automatically (`ROS_X = −AR_Z`, `ROS_Y = −AR_X`,
`ROS_Z = AR_Y`); it is not a user-facing option. If the platform's AR engine ever changes,
that is a one-line internal change.

### Error handling

All errors derive from `AgroWellError`. HTTP errors map to typed exceptions
(`BadRequestError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`, …),
each carrying `status_code`, `response_body`, and `request_url`. `AnchorNotFoundError` is
raised when no anchor matches a tag, and `AmbiguousAnchorError` when more than one does.

### Advanced: dependency injection

```python
client = (
    AgroWellClient.builder()
    .with_settings(settings)
    .add_request_hook(my_tracing_hook)
    .with_token_store(my_token_store)
    .build()
)
```

## Architecture

```
AgroWellClient (facade)
  ├─ api/        ObjectPlacementApi → .anchors, .anchor_groups   (raw request layer, by category)
  └─ alignment   commissioning workflow (composes api: read anchor → compute G → write group)
        │
        └─>  transport/ (httpx)  ──>  auth/ + models/
```

- `api/` is the **single reusable request layer**, grouped by resource category
  (`client.api.anchors`, `client.api.anchor_groups`); domain resources like
  `resources/alignment` compose it rather than issuing requests directly, so each endpoint
  lives in exactly one place and a new category is one module plus one line.
- `Transport`, `AuthStrategy`, `TokenStore` are `typing.Protocol`s (dependency inversion):
  implementations swap and fake in tests without touching call sites.
- **Sync today, async-ready:** the synchronous `HttpxTransport` sits behind the `Transport`
  seam, so an async transport can be added additively later.
- Logging uses a `NullHandler`; nothing is emitted unless your application configures it.
- Pure math (`geometry.py`, `validation.py`) has no I/O; the validation report uploads via a
  pluggable `ReportSink` (`reporting/sinks.py`).

## Development

```bash
make install      # uv sync --extra dev   (or: python3.11 -m venv .venv && .venv/bin/pip install -e ".[dev]")
make check        # ruff + mypy (strict) + pytest
pytest -m integration   # live smoke test (requires AGROWELL_* env)
```

Tests use `respx` to mock httpx and a `FakeTransport` (the `Transport` Protocol) for pure
unit tests — no network and no ROS install required.

## Open items

The full read + commissioning-write flow has been verified live against the homelab
deployment; the notes below are operational requirements, not blockers.

1. **Organization + facility are mandatory:** every read/write is scoped by
   `Grpc-Metadata-organization` (`AGROWELL_ORGANIZATION_ID`) and `sceneId` (`AGROWELL_FACILITY_ID`,
   the platform scene == facility). For the robot's machine token the header **is** the org
   scope; only *user* tokens additionally need a matching `organization` claim.
2. **Group WRITE works with the machine token** *(resolved & verified live —
   core-object-placement v1.4.7 / u2m-go-utils v1.2.0)*: reads **and** `UpdateAnchorsGroup`
   accept the robot's client-credentials token. Caveat: machine clients must **not** carry an
   `email` attribute in Keycloak, or they are classified as user tokens and hit the strict checks.
3. **Report upload:** with `AGROWELL_DEV_MODE=true` and `AGROWELL_MINIO_*` set, the client
   auto-wires `MinioSink` and uploads the PDF on `close()` — `AgroWellClient.from_env()` is all
   the robot needs. `ObjectStoreSink` is the credential-free alternative (PUT to a
   backend-minted presigned URL) for callers that prefer to inject their own sink.
4. **Conventions confirmed live:** the PATCH targets `localization_provider_id`
   (== `anchor.anchors_group_id`); the platform may serialize a `TransformMatrix` in either
   `row_first` or `column_first` layout, which the SDK handles transparently.
5. **Anchors must pre-exist** and be AR-calibrated (with `apriltag_id`s) for the organization.
6. **API prefix** is `/v1` (`AGROWELL_API_PATH_PREFIX`).
7. **Keycloak client:** a service-account client in realm `AGRO-WELL` (+ secret).
8. **Distribution:** released under the BSD 3-Clause License and publishable to
   public PyPI. Scrub the repo (and git history) for secrets / internal hosts before any
   public release — see `.env` (gitignored) and `examples/`.

## License

**BSD 3-Clause License.** Copyright (c) 2026 up2metric P.C. See `LICENSE`.
Redistribution and use in source and binary forms, with or without modification, are
permitted provided the copyright notice and the conditions in `LICENSE` are retained. For
inquiries, contact info@up2metric.com.

## Acknowledgement

This project has received funding from the European Union's Horizon Europe research and
innovation programme under Grant Agreement No. 101182923 (AGRO-WELL).

Developed and maintained by [up2metric P.C.](https://up2metric.com), Athens, Greece.
