agrowell-ikh-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. agrowell_ikh_client-0.1.0/.gitignore +65 -0
  2. agrowell_ikh_client-0.1.0/LICENSE +29 -0
  3. agrowell_ikh_client-0.1.0/PKG-INFO +298 -0
  4. agrowell_ikh_client-0.1.0/README.md +235 -0
  5. agrowell_ikh_client-0.1.0/pyproject.toml +89 -0
  6. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/__init__.py +135 -0
  7. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/_version.py +3 -0
  8. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/__init__.py +28 -0
  9. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/anchor_groups.py +59 -0
  10. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/anchors.py +62 -0
  11. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/base.py +26 -0
  12. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/__init__.py +15 -0
  13. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/base.py +44 -0
  14. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/client_credentials.py +130 -0
  15. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/token_store.py +32 -0
  16. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/client.py +176 -0
  17. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/config.py +83 -0
  18. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/exceptions.py +80 -0
  19. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/geometry.py +51 -0
  20. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/hooks.py +36 -0
  21. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/__init__.py +1 -0
  22. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/anchors.py +43 -0
  23. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/common.py +198 -0
  24. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/localization_provider.py +73 -0
  25. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/py.typed +0 -0
  26. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/reporting/__init__.py +12 -0
  27. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/reporting/pdf.py +388 -0
  28. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/reporting/sinks.py +168 -0
  29. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/resources/__init__.py +7 -0
  30. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/resources/alignment.py +176 -0
  31. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/transport/__init__.py +8 -0
  32. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/transport/base.py +41 -0
  33. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/transport/httpx_transport.py +152 -0
  34. agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/validation.py +638 -0
@@ -0,0 +1,65 @@
1
+ # These are some examples of commonly ignored file patterns.
2
+ # You should customize this list as applicable to your project.
3
+ # Learn more about .gitignore:
4
+ # https://www.atlassian.com/git/tutorials/saving-changes/gitignore
5
+
6
+ # Node artifact files
7
+ node_modules/
8
+ dist/
9
+
10
+ # Compiled Java class files
11
+ *.class
12
+
13
+ # Compiled Python bytecode
14
+ *.py[cod]
15
+
16
+ # Log files
17
+ *.log
18
+
19
+ # Package files
20
+ *.jar
21
+
22
+ # Maven
23
+ target/
24
+ dist/
25
+
26
+ # JetBrains IDE
27
+ .idea/
28
+
29
+ # Unit test reports
30
+ TEST*.xml
31
+
32
+ # Generated by MacOS
33
+ .DS_Store
34
+
35
+ # Generated by Windows
36
+ Thumbs.db
37
+
38
+ # Applications
39
+ *.app
40
+ *.exe
41
+ *.war
42
+
43
+ # Large media files
44
+ *.mp4
45
+ *.tiff
46
+ *.avi
47
+ *.flv
48
+ *.mov
49
+ *.wmv
50
+
51
+ # Python virtual environments
52
+ .venv/
53
+ venv/
54
+ .env
55
+
56
+ # Python build / test caches
57
+ __pycache__/
58
+ *.egg-info/
59
+ build/
60
+ .pytest_cache/
61
+ .mypy_cache/
62
+ .ruff_cache/
63
+ .coverage
64
+ htmlcov/
65
+
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, up2metric P.C.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice,
10
+ this list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of up2metric P.C. nor the names of its contributors
17
+ may be used to endorse or promote products derived from this software
18
+ without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.4
2
+ Name: agrowell-ikh-client
3
+ Version: 0.1.0
4
+ Summary: Python SDK bridging the I Know How (IKH) robot with the Agro-Well greenhouse platform.
5
+ Project-URL: Homepage, https://up2metric.com
6
+ Project-URL: Repository, https://bitbucket.org/up2metricPC/agro-well-ikh-client
7
+ Project-URL: Issues, https://bitbucket.org/up2metricPC/agro-well-ikh-client/issues
8
+ Author-email: "up2metric P.C." <info@up2metric.com>
9
+ License: BSD 3-Clause License
10
+
11
+ Copyright (c) 2026, up2metric P.C.
12
+ All rights reserved.
13
+
14
+ Redistribution and use in source and binary forms, with or without
15
+ modification, are permitted provided that the following conditions are met:
16
+
17
+ 1. Redistributions of source code must retain the above copyright notice,
18
+ this list of conditions and the following disclaimer.
19
+
20
+ 2. Redistributions in binary form must reproduce the above copyright notice,
21
+ this list of conditions and the following disclaimer in the documentation
22
+ and/or other materials provided with the distribution.
23
+
24
+ 3. Neither the name of up2metric P.C. nor the names of its contributors
25
+ may be used to endorse or promote products derived from this software
26
+ without specific prior written permission.
27
+
28
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
29
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
30
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
31
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
32
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
33
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
34
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
35
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
36
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
37
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
38
+ License-File: LICENSE
39
+ Keywords: agro-well,apriltag,keycloak,object-placement,robotics,sdk
40
+ Classifier: Development Status :: 3 - Alpha
41
+ Classifier: Intended Audience :: Developers
42
+ Classifier: License :: OSI Approved :: BSD License
43
+ Classifier: Operating System :: OS Independent
44
+ Classifier: Programming Language :: Python :: 3
45
+ Classifier: Programming Language :: Python :: 3.10
46
+ Classifier: Programming Language :: Python :: 3.11
47
+ Classifier: Programming Language :: Python :: 3.12
48
+ Classifier: Typing :: Typed
49
+ Requires-Python: >=3.10
50
+ Requires-Dist: fpdf2<3,>=2.8
51
+ Requires-Dist: httpx<0.29,>=0.27
52
+ Requires-Dist: pydantic-settings<3,>=2.3
53
+ Requires-Dist: pydantic<3,>=2.7
54
+ Provides-Extra: dev
55
+ Requires-Dist: mypy>=1.11; extra == 'dev'
56
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
57
+ Requires-Dist: pytest>=8; extra == 'dev'
58
+ Requires-Dist: respx>=0.21; extra == 'dev'
59
+ Requires-Dist: ruff>=0.6; extra == 'dev'
60
+ Provides-Extra: minio
61
+ Requires-Dist: minio>=7.2; extra == 'minio'
62
+ Description-Content-Type: text/markdown
63
+
64
+ # agrowell-ikh-client
65
+
66
+ A small, typed Python SDK that bridges the **I Know How (IKH)** robot with the
67
+ **Agro-Well** greenhouse platform.
68
+
69
+ **v1 scope:**
70
+
71
+ 1. **Authenticate** to the platform via Keycloak (client-credentials / machine-to-machine).
72
+ 2. **Read anchors** — resolve a detected AprilTag to its platform anchor (and its AR pose).
73
+ 3. **Commission (align)** — from a reference anchor, compute the anchor-group transform that
74
+ maps the robot's **ROS** measurements onto the platform **AR** scene, write it, and emit a
75
+ quantitative **validation report**. The ROS ↔ Three.js conversion happens **internally**.
76
+
77
+ > **Status:** v1 boilerplate. Some platform-side prerequisites must be confirmed before
78
+ > live integration — see [Open items](#open-items). Open-source under the
79
+ > [BSD 3-Clause License](LICENSE).
80
+
81
+ ## Installation
82
+
83
+ Released under the [BSD 3-Clause License](#license). Install from PyPI:
84
+
85
+ ```bash
86
+ pip install agrowell-ikh-client
87
+ ```
88
+
89
+ Or from a built wheel:
90
+
91
+ ```bash
92
+ pip install ./agrowell_ikh_client-<version>-py3-none-any.whl
93
+ ```
94
+
95
+ Requires Python 3.10+.
96
+
97
+ ## Configuration
98
+
99
+ Read from `AGROWELL_`-prefixed environment variables (and an optional `.env` file), or
100
+ passed explicitly via `Settings`. Copy [`.env.example`](.env.example) to `.env` and fill
101
+ in the values from the Agro-Well platform team.
102
+
103
+ | Variable | Required | Default | Description |
104
+ |---|---|---|---|
105
+ | `AGROWELL_KEYCLOAK_BASE_PATH` | ✅ | — | Keycloak base URL |
106
+ | `AGROWELL_KEYCLOAK_REALM` | | `AGRO-WELL` | Keycloak realm |
107
+ | `AGROWELL_KEYCLOAK_CLIENT_ID` | ✅ | — | Service-account client id |
108
+ | `AGROWELL_KEYCLOAK_CLIENT_SECRET` | ✅ | — | Service-account client secret |
109
+ | `AGROWELL_API_BASE_URL` | ✅ | — | object-placement REST base URL |
110
+ | `AGROWELL_API_PATH_PREFIX` | | `/v1` | API path prefix (`/v1` or `/api/v1`) |
111
+ | `AGROWELL_ORGANIZATION_ID` | ✅ | — | Organization the robot belongs to (scopes all anchor reads/writes) |
112
+ | `AGROWELL_FACILITY_ID` | ✅ | — | Facility the robot is installed at (== platform scene id; scopes every read to that facility) |
113
+ | `AGROWELL_DEV_MODE` | | `false` | Collect a validation report during commissioning and emit it on `close()` |
114
+ | `AGROWELL_VERIFY_SSL` | | `true` | TLS certificate verification |
115
+ | `AGROWELL_HTTP_TIMEOUT_SECONDS` | | `10.0` | Request timeout |
116
+ | `AGROWELL_HTTP_MAX_RETRIES` | | `3` | Retry budget for idempotent requests |
117
+
118
+ ## Quickstart
119
+
120
+ ```python
121
+ from agrowell_ikh_client import AgroWellClient, ScannedTag
122
+
123
+ with AgroWellClient.from_env() as client:
124
+ # Discover the anchors visible to your organization (raw platform models, AR frame):
125
+ anchors = client.api.anchors.list()
126
+
127
+ # Commission: on each AprilTag detection, pass the robot's measured pose (ROS frame).
128
+ # The SDK resolves the anchor, computes the anchor-group transform, and writes it.
129
+ for detection in detections: # your detector's per-frame loop
130
+ client.alignment.update_group(
131
+ ScannedTag(
132
+ apriltag_id=detection.id,
133
+ translation=(1.20, 0.0, 3.45), # metres, ROS frame
134
+ quaternion=(0.0, 0.0, 0.0, 1.0), # (x, y, z, w)
135
+ )
136
+ )
137
+ ```
138
+
139
+ The robot needs only its **organization** (from config) and the **AprilTag ids** it scans.
140
+ It never handles anchor UUIDs, anchors-groups, scenes, or raw 4×4 matrices — those are
141
+ resolved or converted internally. Poses cross the SDK boundary in the **ROS** frame; the
142
+ raw reads under `client.api.*` return platform models in the **AR (Three.js)** frame.
143
+
144
+ ## The rigid-isometry property (why the between-anchor check works)
145
+
146
+ The internal ROS → Three.js conversion is a **rigid isometry** — a pure rotation of axes
147
+ (`det = +1`, no scale or handedness flip), so distances and angles between anchors are
148
+ preserved. The commissioning report exploits this: in the relative transform between two
149
+ anchors the anchor-group transform `G` cancels, so a residual there can only come from the
150
+ ROS ↔ AR conversion itself (a handedness/axis flip or a metre/centimetre mix-up), not from a
151
+ single mis-placed anchor. That is what makes the between-anchor check a clean, objective
152
+ signal even though there is no visual validation. (See `tests/test_simulation.py`.)
153
+
154
+ ## Commissioning (alignment + validation)
155
+
156
+ After the 3 anchors are AR-calibrated in the web app, the robot aligns its ROS frame to the
157
+ scene by writing the **anchor-group transform**. On **each** AprilTag detection it calls
158
+ `update_group`, which resolves the anchor's AR pose, computes the group transform `G` from
159
+ that AR pose and the detected ROS pose (`G = T_ar ∘ inverse(convert(T_ros))`), and PATCHes the
160
+ group. This runs many times during a commissioning session.
161
+
162
+ Because updating that transform moves the whole scene subtree together, there is **no visual
163
+ validation**. So, with `AGROWELL_DEV_MODE=true`, the client collects each anchor's server
164
+ ("before") and ROS-computed ("after") pose and emits one **validation report** on `close()`.
165
+
166
+ ```python
167
+ from agrowell_ikh_client import AgroWellClient, ScannedTag, ObjectStoreSink
168
+
169
+ # AGROWELL_DEV_MODE=true enables the report; the sink is where it is uploaded.
170
+ # Sinks: ObjectStoreSink (presigned URL), MinioSink.from_settings(settings) (direct MinIO/S3,
171
+ # needs the 'minio' extra), or LocalFileSink (disk).
172
+ with AgroWellClient.builder().with_report_sink(
173
+ ObjectStoreSink(presign=mint_upload_url) # mint_upload_url(key) -> presigned PUT URL
174
+ ).build() as client:
175
+ for tag in detections: # the robot's per-detection loop
176
+ client.alignment.update_group(
177
+ ScannedTag(tag.id, tag.translation, tag.quaternion) # ROS frame
178
+ )
179
+ # on exit: one report (per-anchor errors + between-anchor conversion check) is uploaded
180
+ ```
181
+
182
+ The report (`ValidationReport`, render with `report.to_text()`) carries per-anchor
183
+ position/orientation errors and aggregates, **plus a pairwise (between-anchor) check that the
184
+ ROS → AR conversion holds** — in the relative transform between two anchors `G` cancels, so it
185
+ isolates conversion errors (handedness/axis flips, metre/centimetre mix-ups) from a single
186
+ mis-placed anchor. Try it offline: `python examples/simulate_commissioning.py`.
187
+
188
+ ## Usage
189
+
190
+ ```python
191
+ # Raw request layer, grouped by resource category (client.api.<category>).
192
+
193
+ # List the anchors visible to your organization (raw platform models, AR frame).
194
+ # No group/scene needed; the org context scopes the result.
195
+ anchors = client.api.anchors.list() # or .list(registered=False)
196
+
197
+ # Resolve a single detected tag to its anchor. Raises AnchorNotFoundError if none match,
198
+ # AmbiguousAnchorError if more than one does:
199
+ anchor = client.api.anchors.resolve_by_tag(42)
200
+ if anchor.image_anchor and anchor.image_anchor.transform:
201
+ x, y, z = anchor.image_anchor.transform.translation # AR (Three.js) frame
202
+
203
+ # Commission: per detection, compute + write the anchor-group transform from a ROS pose.
204
+ client.alignment.update_group(
205
+ ScannedTag(42, translation=(1.2, 0.0, 3.45), quaternion=(0.0, 0.0, 0.0, 1.0))
206
+ )
207
+ ```
208
+
209
+ ### Coordinate frames (internal)
210
+
211
+ The robot speaks only **ROS** (REP-103: X-forward, Y-left, Z-up, right-handed, metres,
212
+ quaternions). The platform stores transforms in the **Three.js** AR frame (right-handed,
213
+ Y-up). The SDK converts at the boundary automatically (`ROS_X = −AR_Z`, `ROS_Y = −AR_X`,
214
+ `ROS_Z = AR_Y`); it is not a user-facing option. If the platform's AR engine ever changes,
215
+ that is a one-line internal change.
216
+
217
+ ### Error handling
218
+
219
+ All errors derive from `AgroWellError`. HTTP errors map to typed exceptions
220
+ (`BadRequestError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`, …),
221
+ each carrying `status_code`, `response_body`, and `request_url`. `AnchorNotFoundError` is
222
+ raised when no anchor matches a tag, and `AmbiguousAnchorError` when more than one does.
223
+
224
+ ### Advanced: dependency injection
225
+
226
+ ```python
227
+ client = (
228
+ AgroWellClient.builder()
229
+ .with_settings(settings)
230
+ .add_request_hook(my_tracing_hook)
231
+ .with_token_store(my_token_store)
232
+ .build()
233
+ )
234
+ ```
235
+
236
+ ## Architecture
237
+
238
+ ```
239
+ AgroWellClient (facade)
240
+ ├─ api/ ObjectPlacementApi → .anchors, .anchor_groups (raw request layer, by category)
241
+ └─ alignment commissioning workflow (composes api: read anchor → compute G → write group)
242
+
243
+ └─> transport/ (httpx) ──> auth/ + models/
244
+ ```
245
+
246
+ - `api/` is the **single reusable request layer**, grouped by resource category
247
+ (`client.api.anchors`, `client.api.anchor_groups`); domain resources like
248
+ `resources/alignment` compose it rather than issuing requests directly, so each endpoint
249
+ lives in exactly one place and a new category is one module plus one line.
250
+ - `Transport`, `AuthStrategy`, `TokenStore` are `typing.Protocol`s (dependency inversion):
251
+ implementations swap and fake in tests without touching call sites.
252
+ - **Sync today, async-ready:** the synchronous `HttpxTransport` sits behind the `Transport`
253
+ seam, so an async transport can be added additively later.
254
+ - Logging uses a `NullHandler`; nothing is emitted unless your application configures it.
255
+ - Pure math (`geometry.py`, `validation.py`) has no I/O; the validation report uploads via a
256
+ pluggable `ReportSink` (`reporting/sinks.py`).
257
+
258
+ ## Development
259
+
260
+ ```bash
261
+ make install # uv sync --extra dev (or: python3.11 -m venv .venv && .venv/bin/pip install -e ".[dev]")
262
+ make check # ruff + mypy (strict) + pytest
263
+ pytest -m integration # live smoke test (requires AGROWELL_* env)
264
+ ```
265
+
266
+ Tests use `respx` to mock httpx and a `FakeTransport` (the `Transport` Protocol) for pure
267
+ unit tests — no network and no ROS install required.
268
+
269
+ ## Open items
270
+
271
+ These do not block using the SDK, but must be resolved for live integration.
272
+
273
+ 1. **Organization is mandatory:** every anchor read/write is scoped to an organization. The
274
+ SDK sends `Grpc-Metadata-organization` (`AGROWELL_ORGANIZATION_ID`, required) — **and** the
275
+ Keycloak token must carry a matching `organization` claim, or the platform returns
276
+ `ErrOrgHeaderMissing` / `ErrOrgMembershipDenied`.
277
+ 2. **Group WRITE needs a backend change (blocker):** anchor reads accept the robot's
278
+ client-credentials token, but `UpdateAnchorsGroup` (the commissioning write) currently
279
+ requires a *user* token. The platform must relax that RPC to accept machine tokens (org
280
+ kept) before the robot can write the group transform.
281
+ 3. **Report upload:** `ObjectStoreSink` PUTs the report to a presigned URL; a backend endpoint
282
+ must mint it (so no object-store credentials live on the robot).
283
+ 4. **Confirm conventions:** `localization_provider_id` vs `anchor.anchors_group_id` for the
284
+ PATCH, and the `TransformMatrix` orientation / multiplication order (verify with one real
285
+ anchor round-trip).
286
+ 5. **Anchors must pre-exist** and be AR-calibrated (with `apriltag_id`s) for the organization.
287
+ 6. **API prefix:** confirm `/v1` vs `/api/v1` (`AGROWELL_API_PATH_PREFIX`).
288
+ 7. **Keycloak client:** a service-account client in realm `AGRO-WELL` (+ secret).
289
+ 8. **Distribution:** released under the [BSD 3-Clause License](LICENSE) and publishable to
290
+ public PyPI. Scrub the repo (and git history) for secrets / internal hosts before any
291
+ public release — see `.env` (gitignored) and `examples/`.
292
+
293
+ ## License
294
+
295
+ **BSD 3-Clause License.** Copyright (c) 2026 up2metric P.C. See [`LICENSE`](LICENSE).
296
+ Redistribution and use in source and binary forms, with or without modification, are
297
+ permitted provided the copyright notice and the conditions in `LICENSE` are retained. For
298
+ inquiries, contact info@up2metric.com.
@@ -0,0 +1,235 @@
1
+ # agrowell-ikh-client
2
+
3
+ A small, typed Python SDK that bridges the **I Know How (IKH)** robot with the
4
+ **Agro-Well** greenhouse platform.
5
+
6
+ **v1 scope:**
7
+
8
+ 1. **Authenticate** to the platform via Keycloak (client-credentials / machine-to-machine).
9
+ 2. **Read anchors** — resolve a detected AprilTag to its platform anchor (and its AR pose).
10
+ 3. **Commission (align)** — from a reference anchor, compute the anchor-group transform that
11
+ maps the robot's **ROS** measurements onto the platform **AR** scene, write it, and emit a
12
+ quantitative **validation report**. The ROS ↔ Three.js conversion happens **internally**.
13
+
14
+ > **Status:** v1 boilerplate. Some platform-side prerequisites must be confirmed before
15
+ > live integration — see [Open items](#open-items). Open-source under the
16
+ > [BSD 3-Clause License](LICENSE).
17
+
18
+ ## Installation
19
+
20
+ Released under the [BSD 3-Clause License](#license). Install from PyPI:
21
+
22
+ ```bash
23
+ pip install agrowell-ikh-client
24
+ ```
25
+
26
+ Or from a built wheel:
27
+
28
+ ```bash
29
+ pip install ./agrowell_ikh_client-<version>-py3-none-any.whl
30
+ ```
31
+
32
+ Requires Python 3.10+.
33
+
34
+ ## Configuration
35
+
36
+ Read from `AGROWELL_`-prefixed environment variables (and an optional `.env` file), or
37
+ passed explicitly via `Settings`. Copy [`.env.example`](.env.example) to `.env` and fill
38
+ in the values from the Agro-Well platform team.
39
+
40
+ | Variable | Required | Default | Description |
41
+ |---|---|---|---|
42
+ | `AGROWELL_KEYCLOAK_BASE_PATH` | ✅ | — | Keycloak base URL |
43
+ | `AGROWELL_KEYCLOAK_REALM` | | `AGRO-WELL` | Keycloak realm |
44
+ | `AGROWELL_KEYCLOAK_CLIENT_ID` | ✅ | — | Service-account client id |
45
+ | `AGROWELL_KEYCLOAK_CLIENT_SECRET` | ✅ | — | Service-account client secret |
46
+ | `AGROWELL_API_BASE_URL` | ✅ | — | object-placement REST base URL |
47
+ | `AGROWELL_API_PATH_PREFIX` | | `/v1` | API path prefix (`/v1` or `/api/v1`) |
48
+ | `AGROWELL_ORGANIZATION_ID` | ✅ | — | Organization the robot belongs to (scopes all anchor reads/writes) |
49
+ | `AGROWELL_FACILITY_ID` | ✅ | — | Facility the robot is installed at (== platform scene id; scopes every read to that facility) |
50
+ | `AGROWELL_DEV_MODE` | | `false` | Collect a validation report during commissioning and emit it on `close()` |
51
+ | `AGROWELL_VERIFY_SSL` | | `true` | TLS certificate verification |
52
+ | `AGROWELL_HTTP_TIMEOUT_SECONDS` | | `10.0` | Request timeout |
53
+ | `AGROWELL_HTTP_MAX_RETRIES` | | `3` | Retry budget for idempotent requests |
54
+
55
+ ## Quickstart
56
+
57
+ ```python
58
+ from agrowell_ikh_client import AgroWellClient, ScannedTag
59
+
60
+ with AgroWellClient.from_env() as client:
61
+ # Discover the anchors visible to your organization (raw platform models, AR frame):
62
+ anchors = client.api.anchors.list()
63
+
64
+ # Commission: on each AprilTag detection, pass the robot's measured pose (ROS frame).
65
+ # The SDK resolves the anchor, computes the anchor-group transform, and writes it.
66
+ for detection in detections: # your detector's per-frame loop
67
+ client.alignment.update_group(
68
+ ScannedTag(
69
+ apriltag_id=detection.id,
70
+ translation=(1.20, 0.0, 3.45), # metres, ROS frame
71
+ quaternion=(0.0, 0.0, 0.0, 1.0), # (x, y, z, w)
72
+ )
73
+ )
74
+ ```
75
+
76
+ The robot needs only its **organization** (from config) and the **AprilTag ids** it scans.
77
+ It never handles anchor UUIDs, anchors-groups, scenes, or raw 4×4 matrices — those are
78
+ resolved or converted internally. Poses cross the SDK boundary in the **ROS** frame; the
79
+ raw reads under `client.api.*` return platform models in the **AR (Three.js)** frame.
80
+
81
+ ## The rigid-isometry property (why the between-anchor check works)
82
+
83
+ The internal ROS → Three.js conversion is a **rigid isometry** — a pure rotation of axes
84
+ (`det = +1`, no scale or handedness flip), so distances and angles between anchors are
85
+ preserved. The commissioning report exploits this: in the relative transform between two
86
+ anchors the anchor-group transform `G` cancels, so a residual there can only come from the
87
+ ROS ↔ AR conversion itself (a handedness/axis flip or a metre/centimetre mix-up), not from a
88
+ single mis-placed anchor. That is what makes the between-anchor check a clean, objective
89
+ signal even though there is no visual validation. (See `tests/test_simulation.py`.)
90
+
91
+ ## Commissioning (alignment + validation)
92
+
93
+ After the 3 anchors are AR-calibrated in the web app, the robot aligns its ROS frame to the
94
+ scene by writing the **anchor-group transform**. On **each** AprilTag detection it calls
95
+ `update_group`, which resolves the anchor's AR pose, computes the group transform `G` from
96
+ that AR pose and the detected ROS pose (`G = T_ar ∘ inverse(convert(T_ros))`), and PATCHes the
97
+ group. This runs many times during a commissioning session.
98
+
99
+ Because updating that transform moves the whole scene subtree together, there is **no visual
100
+ validation**. So, with `AGROWELL_DEV_MODE=true`, the client collects each anchor's server
101
+ ("before") and ROS-computed ("after") pose and emits one **validation report** on `close()`.
102
+
103
+ ```python
104
+ from agrowell_ikh_client import AgroWellClient, ScannedTag, ObjectStoreSink
105
+
106
+ # AGROWELL_DEV_MODE=true enables the report; the sink is where it is uploaded.
107
+ # Sinks: ObjectStoreSink (presigned URL), MinioSink.from_settings(settings) (direct MinIO/S3,
108
+ # needs the 'minio' extra), or LocalFileSink (disk).
109
+ with AgroWellClient.builder().with_report_sink(
110
+ ObjectStoreSink(presign=mint_upload_url) # mint_upload_url(key) -> presigned PUT URL
111
+ ).build() as client:
112
+ for tag in detections: # the robot's per-detection loop
113
+ client.alignment.update_group(
114
+ ScannedTag(tag.id, tag.translation, tag.quaternion) # ROS frame
115
+ )
116
+ # on exit: one report (per-anchor errors + between-anchor conversion check) is uploaded
117
+ ```
118
+
119
+ The report (`ValidationReport`, render with `report.to_text()`) carries per-anchor
120
+ position/orientation errors and aggregates, **plus a pairwise (between-anchor) check that the
121
+ ROS → AR conversion holds** — in the relative transform between two anchors `G` cancels, so it
122
+ isolates conversion errors (handedness/axis flips, metre/centimetre mix-ups) from a single
123
+ mis-placed anchor. Try it offline: `python examples/simulate_commissioning.py`.
124
+
125
+ ## Usage
126
+
127
+ ```python
128
+ # Raw request layer, grouped by resource category (client.api.<category>).
129
+
130
+ # List the anchors visible to your organization (raw platform models, AR frame).
131
+ # No group/scene needed; the org context scopes the result.
132
+ anchors = client.api.anchors.list() # or .list(registered=False)
133
+
134
+ # Resolve a single detected tag to its anchor. Raises AnchorNotFoundError if none match,
135
+ # AmbiguousAnchorError if more than one does:
136
+ anchor = client.api.anchors.resolve_by_tag(42)
137
+ if anchor.image_anchor and anchor.image_anchor.transform:
138
+ x, y, z = anchor.image_anchor.transform.translation # AR (Three.js) frame
139
+
140
+ # Commission: per detection, compute + write the anchor-group transform from a ROS pose.
141
+ client.alignment.update_group(
142
+ ScannedTag(42, translation=(1.2, 0.0, 3.45), quaternion=(0.0, 0.0, 0.0, 1.0))
143
+ )
144
+ ```
145
+
146
+ ### Coordinate frames (internal)
147
+
148
+ The robot speaks only **ROS** (REP-103: X-forward, Y-left, Z-up, right-handed, metres,
149
+ quaternions). The platform stores transforms in the **Three.js** AR frame (right-handed,
150
+ Y-up). The SDK converts at the boundary automatically (`ROS_X = −AR_Z`, `ROS_Y = −AR_X`,
151
+ `ROS_Z = AR_Y`); it is not a user-facing option. If the platform's AR engine ever changes,
152
+ that is a one-line internal change.
153
+
154
+ ### Error handling
155
+
156
+ All errors derive from `AgroWellError`. HTTP errors map to typed exceptions
157
+ (`BadRequestError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`, …),
158
+ each carrying `status_code`, `response_body`, and `request_url`. `AnchorNotFoundError` is
159
+ raised when no anchor matches a tag, and `AmbiguousAnchorError` when more than one does.
160
+
161
+ ### Advanced: dependency injection
162
+
163
+ ```python
164
+ client = (
165
+ AgroWellClient.builder()
166
+ .with_settings(settings)
167
+ .add_request_hook(my_tracing_hook)
168
+ .with_token_store(my_token_store)
169
+ .build()
170
+ )
171
+ ```
172
+
173
+ ## Architecture
174
+
175
+ ```
176
+ AgroWellClient (facade)
177
+ ├─ api/ ObjectPlacementApi → .anchors, .anchor_groups (raw request layer, by category)
178
+ └─ alignment commissioning workflow (composes api: read anchor → compute G → write group)
179
+
180
+ └─> transport/ (httpx) ──> auth/ + models/
181
+ ```
182
+
183
+ - `api/` is the **single reusable request layer**, grouped by resource category
184
+ (`client.api.anchors`, `client.api.anchor_groups`); domain resources like
185
+ `resources/alignment` compose it rather than issuing requests directly, so each endpoint
186
+ lives in exactly one place and a new category is one module plus one line.
187
+ - `Transport`, `AuthStrategy`, `TokenStore` are `typing.Protocol`s (dependency inversion):
188
+ implementations swap and fake in tests without touching call sites.
189
+ - **Sync today, async-ready:** the synchronous `HttpxTransport` sits behind the `Transport`
190
+ seam, so an async transport can be added additively later.
191
+ - Logging uses a `NullHandler`; nothing is emitted unless your application configures it.
192
+ - Pure math (`geometry.py`, `validation.py`) has no I/O; the validation report uploads via a
193
+ pluggable `ReportSink` (`reporting/sinks.py`).
194
+
195
+ ## Development
196
+
197
+ ```bash
198
+ make install # uv sync --extra dev (or: python3.11 -m venv .venv && .venv/bin/pip install -e ".[dev]")
199
+ make check # ruff + mypy (strict) + pytest
200
+ pytest -m integration # live smoke test (requires AGROWELL_* env)
201
+ ```
202
+
203
+ Tests use `respx` to mock httpx and a `FakeTransport` (the `Transport` Protocol) for pure
204
+ unit tests — no network and no ROS install required.
205
+
206
+ ## Open items
207
+
208
+ These do not block using the SDK, but must be resolved for live integration.
209
+
210
+ 1. **Organization is mandatory:** every anchor read/write is scoped to an organization. The
211
+ SDK sends `Grpc-Metadata-organization` (`AGROWELL_ORGANIZATION_ID`, required) — **and** the
212
+ Keycloak token must carry a matching `organization` claim, or the platform returns
213
+ `ErrOrgHeaderMissing` / `ErrOrgMembershipDenied`.
214
+ 2. **Group WRITE needs a backend change (blocker):** anchor reads accept the robot's
215
+ client-credentials token, but `UpdateAnchorsGroup` (the commissioning write) currently
216
+ requires a *user* token. The platform must relax that RPC to accept machine tokens (org
217
+ kept) before the robot can write the group transform.
218
+ 3. **Report upload:** `ObjectStoreSink` PUTs the report to a presigned URL; a backend endpoint
219
+ must mint it (so no object-store credentials live on the robot).
220
+ 4. **Confirm conventions:** `localization_provider_id` vs `anchor.anchors_group_id` for the
221
+ PATCH, and the `TransformMatrix` orientation / multiplication order (verify with one real
222
+ anchor round-trip).
223
+ 5. **Anchors must pre-exist** and be AR-calibrated (with `apriltag_id`s) for the organization.
224
+ 6. **API prefix:** confirm `/v1` vs `/api/v1` (`AGROWELL_API_PATH_PREFIX`).
225
+ 7. **Keycloak client:** a service-account client in realm `AGRO-WELL` (+ secret).
226
+ 8. **Distribution:** released under the [BSD 3-Clause License](LICENSE) and publishable to
227
+ public PyPI. Scrub the repo (and git history) for secrets / internal hosts before any
228
+ public release — see `.env` (gitignored) and `examples/`.
229
+
230
+ ## License
231
+
232
+ **BSD 3-Clause License.** Copyright (c) 2026 up2metric P.C. See [`LICENSE`](LICENSE).
233
+ Redistribution and use in source and binary forms, with or without modification, are
234
+ permitted provided the copyright notice and the conditions in `LICENSE` are retained. For
235
+ inquiries, contact info@up2metric.com.