Metadata-Version: 2.4
Name: lekit-robot
Version: 0.4.1
Summary: Small Python SDK for observing and controlling Lekit Robot App
Project-URL: Repository, https://github.com/sorelferris/lekit-robot
Project-URL: Issues, https://github.com/sorelferris/lekit-robot/issues
Author: Sorel Ferris
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: eclipse-zenoh<2,>=1.10
Requires-Dist: exceptiongroup<2,>=1.2; python_version < '3.11'
Requires-Dist: httpx<1,>=0.28
Requires-Dist: numpy<3,>=1.26
Requires-Dist: pydantic<3,>=2
Requires-Dist: rich<16,>=13.7
Requires-Dist: typing-extensions<5,>=4.6
Provides-Extra: images
Requires-Dist: lz4<5,>=4.4; extra == 'images'
Requires-Dist: pillow<13,>=10; extra == 'images'
Requires-Dist: simplejpeg<2,>=1.9; extra == 'images'
Requires-Dist: zstandard<1,>=0.23; extra == 'images'
Description-Content-Type: text/markdown

# lekit-robot

Python SDK for a running **Lekit Robot App**, requiring Python 3.10+.
Each application owns its SDK, subscriptions and caches. There is no shared SDK
server. The client does not install Robot App, Web, LeRobot, Torch, OpenCV or
hardware drivers.

## Install

Install or upgrade the client from PyPI (Python 3.10+):

```sh
python -m pip install --upgrade lekit-robot==0.4.1
# Include RGB/depth image decoding:
python -m pip install --upgrade 'lekit-robot[images]==0.4.1'
```

Protocol and transport implementations are included inside this SDK distribution.
No separate `lekit-robot-protocol` or `lekit-robot-transport` PyPI packages are required.

Omit `[images]` for state, metadata and encoded images. NumPy is a base dependency;
JPEG/PNG and LZ4/Zstandard codecs are optional. Use `images=False` or
`images="encoded"` without the image extra.

Build the single SDK distribution from the repository root:

```sh
uv build --package lekit-robot --out-dir /tmp/lekit-client-wheels
python -m pip install /tmp/lekit-client-wheels/lekit_robot-0.4.1-py3-none-any.whl
```

For editable development, use the repository workspace, which supplies the canonical
shared sources used by Robot App as well:

```sh
uv sync --all-packages
```

Robot App must already be running and advertise name discovery. Connect by its
configured robot name on the same LAN; no URL is needed. Zenoh multicast discovery
must be reachable. Routed VPNs or networks that block multicast are not discovered
automatically. A loopback-only App remains accessible only on its own host.
Updating SDK packages does not deploy or restart a robot.

## What's new in 0.4.1

- Name discovery and a unified robot-scoped manifest validate the selected
  robot's identity and generation before setup.
- Persistent body/camera subscriptions and per-source cursors let callers wait
  for genuinely new observations with `after` and `freshness="all"`.
- Updated NERO servers expose joint torque alongside position, with independent
  feedback timestamps and validity. Torque is a current-derived estimate in N·m;
  upgrading the client alone cannot add fields missing from an older server.
- Shared protocol/transport code ships in one SDK package; image decoding is
  available through the optional `[images]` extra.

This release targets the current Robot App architecture and control protocol 3.
Applications using the old server-aligned observation mode must migrate for
device-source operation. Full release notes are included as `CHANGELOG.md` in
the source distribution.

## Public application API

| Entry | Purpose |
| --- | --- |
| `connect("NERO Dual")` | Discover an App by name, verify its identity and register a control session without acquiring control |
| `robot.meta_info` | Read a copy of cached metadata; no network request |
| `robot.observation(...)` | Read selected observations and current control feedback |
| `robot.send_action(action)` | Enqueue an absolute target; return its action ID |
| `robot.stop()` | Enqueue a stop boundary |
| `robot.close()` | Close resources; `with` does this automatically |
| `connect_devices(directory, ...)` | Explicit read-only subscriptions to a fixed set of sources |

```python
from lekit_robot import connect

with connect("NERO Dual") as robot:
    meta = robot.meta_info
    previous = robot.observation(cameras=(), images=False, on_timeout="raise")
    current = robot.observation(cameras=(), images=False, after=previous,
                                freshness="all", timeout=.5, on_timeout="raise")
    print(meta.manifest.robot_name, current.robot.state)
    print(current.quality.ages_s)
    print(current.control.feedback)
```

[basic_api.py](examples/basic_api.py) demonstrates the complete lifecycle. It
runs read-only unless an action file is supplied:

```sh
python packages/lekit_robot/examples/basic_api.py --name "NERO Dual"
```

The demo's initial observation selects all advertised cameras and decodes images,
so install `[images]` when cameras are configured. Passing `--action-file target.json`
explicitly enables its control loop; `--steps` sets the number of iterations.
The loop targets 60 Hz but observation, computation and scheduling share that
budget. It does not treat local submission or a stop ID as physical completion.

For a finite, observation-only example, use [read_observation.py](examples/read_observation.py):

```sh
python packages/lekit_robot/examples/read_observation.py --name "NERO Dual" --mode body --count 5
python packages/lekit_robot/examples/read_observation.py --name "NERO Dual" --mode rgbd --camera head_view --count 5
```

Omit `--camera` to select all advertised cameras in image modes. This example
requires every selected source to advance after the first observation, reports
quality and per-source ages, and closes subscriptions automatically. Use
`--full-state` to print the complete body state dictionary.

### NERO joint torque

An updated NERO Robot App includes joint torque in the same body samples as
joint positions. The existing client reads it from `observation.robot.state`:

```python
with connect("NERO Dual") as robot:
    previous = None
    for _ in range(60):
        previous = robot.observation(cameras=(), images=False, after=previous,
                                     freshness="all", timeout=.5, on_timeout="raise")
        state = previous.robot.state or {}
        key = "left.joint_1"  # left/right, joint_1 through joint_7
        torque = state.get(key + ".torque")
        if state.get(key + ".torque_feedback_fresh"):
            print(state.get(key), torque, state["joint_torque.unit"])
```

`joint_torque.estimated=True` identifies the vendor SDK's current-derived
estimate in N·m, not a torque sensor or an external-contact torque measurement.
Each joint includes `.torque_feedback_timestamp_s` (device-host Unix receive
time), `.torque_feedback_age_s` (age when sampled), and `.torque_feedback_fresh`.
Missing, nonfinite, expired or disconnected feedback has no `.torque` value;
zero is a valid measurement. Torque freshness is independent of position and
the overall observation quality. Use the per-joint flag, and do not subtract
this device timestamp from a different host's clock.

Torque is read directly from the latest motor feedback on **every body sampling
cycle**, bypassing the slower diagnostic snapshot. It uses the existing body
stream, with no extra HTTP request or subscription. A 60 Hz body stream does not
guarantee 60 distinct torque readings if the motor feedback is slower; compare
the per-joint timestamps to detect updates. Deploying the server change is
required; installing the client alone cannot add absent server fields.

## Production discovery and process boundaries

`connect("NERO Dual")` queries the LAN discovery responders for that robot
name (trimmed and case-insensitive; other characters must match). It rejects
duplicate names and conflicting robot/instance identities instead of choosing
the first response. The announcement supplies `robot_id`, process instance,
generation and API addresses; the caller supplies no URL. Discovery is part of
each App process, not a central registry or an additional process.

The client reads `GET /api/robots/<robot-id>/manifest` at a discovered address,
with the ID encoded as one URL path segment. The unified manifest describes
identity, capabilities, sources and interfaces. It must match the discovery
announcement before the SDK registers a control session. An explicit
`mode="device-sources"` selects the production source architecture:

1. Project the source directory from `manifest.sources`, using the same
   manifest's embedded body identity; verify its manager, body instance and
   generation. This does not require separate directory and v1 manifest reads.
2. Establish the original App control backend. `auto` / `zenoh` read
   `/api/robots/<robot-id>/realtime` and verify the control identity; an explicit
   `endpoint=` changes that control endpoint, not the individual source addresses.
3. On the first `observation()`, connect directly to the selected body and camera
   sources. Validate each descriptor and clock bound, then combine in this process.

Robot App's public robot APIs use `/api/robots/<robot-id>/<api-name>`, including
configuration, diagnostics, observation and control. Schema versions remain in
payloads. `/api/robots/<robot-id>/legacy-manifest` exposes the old v1 body-only
contract; it is distinct from the unified `/manifest` document.

Only HTTP 404 during initial read-only discovery permits trying the older
`/api/device-manifest` and `/api/manifest/v2` aliases. If no unified manifest is
available, older services can use `/api/transport`, `/api/devices` and
`/api/manifest` as the compatibility description path. Invalid documents,
identity conflicts and non-404 responses do not select an older alias. Once the
robot-scoped API is verified, later reads and actions stay bound to that robot;
a missing scoped endpoint never downgrades to an unscoped default robot.

The body source runs inside Robot App beside local feedback sampling and control.
Each camera child owns its acquisition, RGB-D pairing, encoded cache, source
publisher and finite subscription leases. App manages their lifecycle and the
read-only directory; it does not default to receiving and rebroadcasting all images.
Source listening ports come from the directory and are not all port 7447.
Zenoh is the current replaceable network adapter; no router process is required.
Web uses a fixed App HTTP/WebSocket upstream. Its browser first reads the
Web-owned `/api/robots` list, then the selected robot's scoped manifest; this
single-upstream list is not automatic Fleet discovery. Previews connect directly
to the camera MJPG ports declared in the manifest. Those camera-owned endpoints
retain their own URLs. Web does not import this SDK or combine observations.

For compatibility or debugging, `connect(url="http://ROBOT_IP:8000")` explicitly
selects an API endpoint; adding `name="NERO Dual"` checks its name. A Web backend
that proxies the API is also valid, while a static UI server alone is not.
Neither omitted arguments nor failed name discovery fall back to localhost.
`endpoint=` overrides the control middleware endpoint after resolving the App;
it is not a robot-discovery address. HTTP-only or older Apps without the
`name_discovery` capability still require this explicit URL path.

`transport="http"` explicitly selects body-only compatibility: pass `cameras=()`.
It does not reconstruct mixed body/camera observations through App. `auto` falls
back to the original HTTP control backend only when realtime discovery returns
404; transport failures do not silently change routes or replay motion.
An App declaring `integrated-body`, or a legacy App without the new mode, keeps
its own advertised compatibility behavior. New static diagrams and SDK installs
are not evidence that a particular running App has been upgraded.

## Source subscription and observation contract

The following describes `connect()` against `device-sources`:

- Default selection is body plus all advertised cameras, decoded color and enabled
  depth, `strict=True`. `cameras=()` selects only the body; `robot=False` selects
  only cameras. Names and stable camera IDs are accepted. `images="encoded"`
  retains encoded payloads; `images=False` selects metadata without image streams.
  Metadata selection still includes cameras unless `cameras=()` is explicit.
- One active source/stream selection is retained per client. Repeated calls reuse
  its subscriptions and bounded latest caches. Changing source or stream selection
  closes the old set before opening the new one. Image subscriptions remain active
  between calls until selection changes or the client closes; there is **no idle
  automatic unsubscribe**. Lost clients are bounded by source lease expiry.
- Without `after`, a call may return the same fresh cached samples. `after=previous`
  must belong to this client’s current subscription and source/stream selection.
  `freshness="any"` requires at least one selected source to advance;
  `freshness="all"` requires every source to advance and is limited by the slowest
  source. This is not an instruction to generate samples at the caller's rate.
- Sources have independent identity, instance, generation, sequence and clock.
  RGB-D blocks belong to the same camera frameset. Source replacement requires
  reconnecting; old cursors, sessions and actions never silently rebind.
- Only `mode="latest"` is supported. `mode="aligned"` and historical `at` are
  explicitly rejected. Latest software combination is not hardware synchronization
  or a lossless recording API. There is no cross-device atomic configuration revision.
- Default maximum ages are 250 ms for body and cameras; maximum skew is 50 ms.
  Override with `max_age={"robot": .1, "camera": .2, "head_view": .15}` or source
  IDs, and `max_skew=.03`. Quality is checked again at delivery after decoding and
  management refresh. `timeout` also bounds subscription setup and serialized waits;
  it is not a hard realtime completion guarantee.
- `quality.ages_s` contains capture-age **upper bounds by source ID**, using
  separately measured clock-offset intervals. `receipt_ages_s` measures local
  time since receipt; `clock_uncertainty_s` reports clock interval width.
  Cross-domain skew uses conservative transformed intervals. Host monotonic clocks
  are never subtracted directly. Software clock bounds assume 1 ms plus 100 ppm
  drift allowance and expire after 10 seconds; they are not hardware clock sync.
- After-timeout always raises. With no `after`, default `on_timeout="reuse"` can
  return the last valid matching selection and quality constraints, marked
  `quality.valid=False`, `reused=True`, with increasing ages and `reuse_age_s`.
  It is not a new sample. Use `on_timeout="raise"` for explicit failure instead.
  Identity, lease and clock failures cannot fall back to old data.
- `strict=False` may expose received data failing quality checks with reasons;
  it cannot bypass missing sources, identity changes or `after`. Inspect
  `quality.valid` and `quality.reasons` before using observations.
- Returned `source_cursors` and `source_calibrations` preserve per-source
  evidence. The explicit `connect_devices()` client also exposes its selected
  descriptors through `devices`. The body carries its sampled App config revision; camera-only results
  use zero rather than inventing a global revision. Metadata refresh happens
  during observation delivery when needed; property reads remain local.

The App's bounded HTTP/LOBS compatibility snapshots remain separate. Some old
services support server-side aligned/historical selection and idle stream release;
those semantics do not apply to device-source subscriptions. See the
[device API](../../docs/device-api.md),
[source contracts](../../docs/device-services-plan.md) and
[middleware contract](../../docs/realtime-middleware.md).

## Explicit read-only device API

`connect_devices()` accepts an App base URL or a `DeviceDirectory` snapshot.
For name-based discovery without a control session, obtain the unified manifest
with the SDK discovery helper and project its directory as shown below.
`connect_devices()` opens a fixed set of source subscriptions immediately and creates no control
session. Use the actual registered IDs; production body IDs are `body:<robot_id>`.

```python
from lekit_robot import connect_devices
from lekit_robot.discovery import discover_manifest

found = discover_manifest("NERO Dual")
directory = found.manifest.source_directory()
body_ids = tuple(entry.device.identity.device_id
                 for entry in directory.devices if entry.device.kind == "body")
with connect_devices(directory, source_ids=body_ids, streams=()) as sources:
    previous = sources.observation(images=False)
    current = sources.observation(after=previous, freshness="all", images=False, timeout=.5)
    print(current.robot.state, current.source_cursors)
```

`streams=()` creates no image demand. For this fixed-selection API,
`observation(images=False)` skips decoding but **does not change** already selected
image streams or release their leases. Choose streams when connecting and close
when finished. It has latest/after any/all quality checks but no timeout-reuse
option, actions or automatic re-registration after source replacement.

Reading the directory does not establish subscriptions or wake cameras. Established
subscriptions do not need directory HTTP on every frame. However, production App
owns body and camera lifetimes: App exit closes the body source and stops camera
children. The separate-manager simulation's sources surviving manager exit is
not a claim that production cameras survive App exit.

## Read-only diagnostics and examples

To show this SDK instance in `/monitor` → “观测链路”, explicitly enable
`robot.start_observation_diagnostics(label="policy")` after `connect("NERO Dual")`.
It reports bounded counters and timing summaries every two seconds over the
existing management session; enabling reporting does not start observations or
wake cameras. Continue calling `observation()` in your application.
`robot.observation_diagnostics()` reads a local snapshot and
`robot.stop_observation_diagnostics()` removes the report without stopping the
observation subscription. Closing the robot also cleans up reporting.
Requires the App's `observation_diagnostics.version=1` source-middleware capability.

```sh
python packages/lekit_robot/examples/diagnostics_observation.py --name "NERO Dual" --seconds 30 --images decoded
```

The flow view separates source capture/send from SDK receive/decode/return and
all-source advancement. Reports are client measurements, not server certification;
stale or absent reports are explicitly labeled. See
[observation diagnostics](../../docs/observation-observability.md) for clocks,
bounded windows, failure categories and cleanup semantics.

```sh
lekit-robot --help
python -m lekit_robot --help
lekit-robot --name "NERO Dual" --observation --mode robot --count 300
lekit-robot --name "NERO Dual" --observation --mode encoded --camera head_view
lekit-robot --name "NERO Dual" --observation --mode rgbd --verbose
python packages/lekit_robot/examples/benchmark_observation.py --name "NERO Dual" --mode robot --count 300
```

The benchmark example waits for **every selected source to advance** before
counting a new observation (`after=previous, freshness="all"`). It reports return
intervals, unique updates per source and skipped sequence numbers separately.
It does not add a second 60 Hz sleep after waiting for the devices. Use
`--poll-latest --hz 60` only to measure periodic cache reads; repeated reads do
not demonstrate 60 Hz fresh delivery.

The CLI runs on the client computer and never sends motion. `--hz` sets the
application's target call rate, not the device capture rate. Source subscriptions
remain active between calls; each call does not create a new request/reply stream.
The default is 60 Hz and all configured RGB-D cameras. Choose `--mode robot` for
no image demand, `encoded` without decoding, or install `[images]` for `rgb`/`rgbd`.
`--camera` is repeatable. Ctrl+C / SIGTERM closes the client; `--count` bounds calls.
The positional form `lekit-robot "NERO Dual" --observation` is also supported.
Provide a name or an explicit `--url`; there is no default local robot.

The dashboard reports return Hz, call percentiles, per-source updates and repeats,
invalid timeout reuse, CPU and memory in a bounded three-second window. CPU 100%
is one core. Returned count and update Hz are not capture frequency, and repeats
are not proof of dropped frames. Diagnostic work adds overhead.

In device-source mode, displayed sample age is the SDK's capture-age upper bound.
Legacy selection-time differences are a separate metric. Optional
`--clocks-synchronized` enables client UTC minus source capture UTC estimates;
it is a user assertion, not automatic clock synchronization. Negative estimates
are labeled clock error and excluded from percentiles. Hardware CAN message age
is separate from the body snapshot age.

The CLI accepts `--observation-mode aligned` only for compatibility with services
supporting old aligned reads; device-source mode rejects it. The benchmark uses
`on_timeout="raise"` so timeouts are failures, not successful reused returns.
Its JSON labels source-mode age upper bounds separately from compatibility
selection-time age. Neither tool certifies stable 60 Hz or real robot stopping.

For a hardware-free transport rehearsal, the repository provides:

```sh
.venv/bin/python scripts/exercise-device-services.py --seconds 60 --cameras 3
```

Its separate simulated processes send pre-encoded frames. It does not validate
physical capture, per-frame encoding, hardware motion or another machine's network.

## Control and safety

`send_action()` and `stop()` require control protocol 3. Both return an action ID
following local enqueue, not device acceptance, dispatch, arrival or confirmed
standstill. The application owns policy, pacing and recording.

- Connecting registers a session without occupying the robot. The first valid
  action claims whole-robot control; competing writers receive `CONTROL_BUSY`.
- Targets are absolute. Joint/gripper field order and units come from metadata.
  Pose supports `{position, orientation, frame}` with a unit xyzw quaternion, or
  `{position, rpy, frame}`. Groups come from the robot description rather than
  assuming every robot has left/right arms.
- Each scope keeps the latest pending target. There is no hidden periodic resend,
  transport retry, atomic multi-arm transaction or replay on reconnect. Action and
  stop carry ordered steps and session fences so delayed old targets cannot revive.
- App retains ownership, freshness, mode, fault, emergency-stop, communication,
  limit and generation checks, including checks before actual dispatch. IK and
  final safety decisions remain on the device, not in Web or the SDK.
- Accepted work survives client close. `close()` discards unsent work and does not
  stop accepted motion. Explicit `stop()` cancels targets and releases ownership
  only after confirmed hold; failures keep the safety barrier locked. Observe
  matching feedback before closing when delivery or hold confirmation matters.
- Normal idle release requires 2 seconds without an accepted action, all latest
  targets reached, and no pending dispatch or stop. The hardware retains its
  position target. A slow one-shot target is not cancelled merely after 2 seconds.
- Default execution protection is 30 seconds per unresolved target and 10 seconds
  without measured progress. This software policy is not a hardware emergency stop.
- `obs.control` carries local status, asynchronous feedback and local monotonic
  feedback-receipt age. It is copied at return, not aligned to sensor capture.
  Check per-part errors and rejection as well as the top-level device fault;
  an empty error does not prove completion.

See [CONTROL_PROTOCOL.md](CONTROL_PROTOCOL.md) and the complete
[basic_api.py](examples/basic_api.py) demo for control confirmation.
`get_observation`, iterators, explicit acquire/release and old target setters are
not public compatibility aliases. Use `observation`, `send_action`, `stop` and
`meta_info`. The wire namespace alone does not guarantee compatibility; upgrade
App and SDK together and reconnect after instance or generation changes.

## Distribution verification

Build/install the single SDK wheel, outside the source
workspace with no `PYTHONPATH`. Verify imports, both CLI help entry points, the
read-only demo, `connect()` and `connect_devices()` against an isolated Mock App.
Use a separate image-extra environment to verify decoding. Package installation
and Mock checks do not imply deployment, real camera throughput or physical safety
acceptance. Publish only after separately verifying the intended registry/version;
this repository's source version is not automatically a released artifact.

Release builds package the canonical shared sources under `lekit_robot._vendor`
and relocate internal imports at build time. They neither install nor alias the
top-level shared modules used by Robot App. The sdist includes the shared source
and licenses and can rebuild without this repository. Use `lekit_robot` public
types in SDK applications; serialized protocol compatibility is retained, while
private bundled classes are not the same Python class objects as separately
loaded App classes. Editable workspace development continues to share App types.

License: Apache-2.0.
