Metadata-Version: 2.4
Name: wisent-wire-sdk
Version: 0.8.0rc2
Summary: Python client for the Wisent Wire hardware testing platform — power, GPIO, UART/RS485/CAN, firmware flashing, GDB debug, and lab reservations
Author-email: Wisent Wire <info@wisent-wire.com>
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28
Requires-Dist: tenacity>=8.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: responses>=0.23; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: types-requests>=2.28; extra == "dev"
Dynamic: license-file

# Wisent Wire Python SDK

Python client library for the [Wisent Wire](https://wisent-wire.com) hardware
testing platform. A single typed client wraps the whole REST API — device
control, firmware flashing, on-target debugging, serial and GPIO I/O,
telemetry, reservations, and organization management — with automatic auth,
typed responses, typed errors, and convenience pollers for asynchronous
operations.

## Installation

```bash
pip install wisent-wire-sdk
```

## Quick start

```python
from wisentwire import WisentWireClient

# API key (recommended — scripts, CI, MCP)
client = WisentWireClient(
    api_key="wwk_...",   # or set the WISENTWIRE_API_KEY env var and omit api_key
)

# Cognito (email + password)
client = WisentWireClient(
    email="user@company.com",
    password="secret",
)

# Pre-existing bearer token
client = WisentWireClient(token="eyJhbGciOi...")
```

Create an API key from the web app under **Settings → API keys**. Keys are
shown once — store them securely. Default expiry is 1 year; up to 10 active
keys per user. Auth resolution precedence: explicit `api_key` >
`WISENTWIRE_API_KEY` env > `token` > `email` + `password`.

### Choosing an environment

`base_url` is optional and defaults to production. To target a different
backend, pass `env=` instead of typing a URL:

```python
client = WisentWireClient(env="int", api_key="wwk_...")   # target int
```

Or set it ambiently, so the same script runs against either backend unchanged:

```bash
export WISENT_WIRE_ENV=int
```

Valid `env` values are `prod` (`https://app.wisent-wire.com`) and `int`
(`https://int.wisent-wire.com`). The value is case-insensitive; an unknown name
raises `ValueError`. An explicit URL still works when you need one:

```python
client = WisentWireClient(base_url="https://app.wisent-wire.com", api_key="wwk_...")
```

Base-URL resolution precedence:

`base_url=` > `env=` > `$WISENT_WIRE_URL` > `$WISENT_WIRE_ENV` > production
(`https://app.wisent-wire.com`).

`WISENTWIRE_URL` / `WISENTWIRE_ENV` are accepted as aliases, matching the
`WISENTWIRE_API_KEY` spelling. Omitting all of them targets production, as
before.

## Features

- **Wires** — list, inspect, create, update, delete wires; configure per-bus
  communications (UART / RS485 / CAN); check availability.
- **Power supply** — set state / voltage / current, read the device shadow,
  toggle and read power telemetry.
- **Firmware & flashing** — list, upload, and delete firmware; dispatch a flash
  job, poll its status by id, stream flasher logs.
- **Flasher catalog** — list programmers, targets, interfaces, toolchains, and
  the valid launch-command combinations.
- **Debug (GDB)** — start / stop an on-target GDB server, read live session
  state, poll the dispatched job by id.
- **Console (UART / RS485 / CAN)** — send hex or ASCII on a byte-stream bus,
  send structured CAN frames, read the per-bus console, wait for an RX match.
- **GPIO** — read the shadow, drive outputs, set input pull policy, name pins,
  toggle and read input telemetry.
- **Device registry** — list registered and connected USB devices, register,
  update and remove devices, trigger a device scan.
- **Frame definitions** — CRUD for protocol frame definitions and their
  commands.
- **Reservations** — list a week's reservations, inspect, create, and delete
  them.
- **Organization** — create and read the org, manage members and roles,
  invitations, and membership requests.
- **Account & system** — read the authenticated user, check auth, and report
  the backend version and deployment stage.
- **Typed errors & pollers** — every non-2xx maps to a `WisentWireError`
  subclass; `wait_*` helpers poll asynchronous jobs with a configurable timeout.

## Usage

### Wires

```python
wires = client.list_wisentwires()
ww = client.get_wisentwire(ww_id=1)
print(ww.name, ww.is_virtual, ww.status, ww.connected)

# Per-bus communications — one entry per configured bus
from wisentwire import CanCommunications, UartCommunications

client.update_wisentwire(
    ww_id=1,
    name="bench-1",
    communications=[
        UartCommunications(baud_rate=115200),
        CanCommunications(bitrate=500_000, termination=True),
    ],
)
print(ww.uart, ww.can)   # convenience accessors; None when not configured
```

`communications=None` keeps the current config; an empty list clears all buses.

### Power supply

```python
client.set_power_supply(ww_id=1, state="ON", voltage=5.0, current=2.0)
shadow = client.wait_shadow_state(ww_id=1, expected_state="ON")
print(shadow.reported.voltage_setpoint)

client.set_power_supply_telemetry(ww_id=1, enabled=True)
readings = client.get_power_telemetry(ww_id=1)
```

### Firmware & flashing

```python
# Upload firmware (one call: presigned URL -> S3 -> confirm)
fw = client.upload_firmware("app.bin", firmware_bytes)

# Resolve a valid (target, programmer, interface, toolchain) combination
cmd = client.list_launch_commands()[0]

# Dispatch a flash job and wait for it by job id
dispatch = client.flash(
    ww_id=1,
    firmware_id=fw.id,
    target_id=cmd.target_id,
    programmer_id=cmd.programmer_id,
    interface_id=cmd.interface_id,
    toolchain_id=cmd.toolchain_id,
)
status = client.wait_flash_complete(ww_id=1, job_id=dispatch.job_id)
print(status.status)

for entry in client.get_flasher_logs(ww_id=1):
    print(entry.source, entry.data)

# Manage stored binaries
binaries = client.list_firmware()
client.delete_firmware(firmware_id=fw.id)
```

`upload_firmware` wraps the three-step flow; `request_upload_url` and
`confirm_firmware` are available if you need to drive the S3 PUT yourself.

### Debug (GDB)

```python
cmd = client.list_launch_commands()[0]
dispatch = client.start_debug(
    ww_id=1,
    target_id=cmd.target_id,
    programmer_id=cmd.programmer_id,
    interface_id=cmd.interface_id,
    toolchain_id=cmd.toolchain_id,
    port=3333,
)
client.wait_debug_complete(ww_id=1, job_id=dispatch.job_id)

state = client.get_debug_state(ww_id=1)
print(state.active, state.local_ip, state.port)
client.stop_debug(ww_id=1)
```

### Console (UART / RS485 / CAN)

```python
client.send_uart_ascii(ww_id=1, text="HELLO")
msg = client.wait_for_uart_rx(ww_id=1, match_hex="48454C4C4F")
print(msg.direction, msg.protocol, msg.bytes_hex)
```

Byte-stream buses (`uart`, `rs485`) take raw hex; CAN is frame-oriented:

```python
from wisentwire import CanFrame

client.send_bus(ww_id=1, bus="rs485", hex_data="DEADBEEF")
client.send_can_frame(ww_id=1, frame=CanFrame(id="0x100", data_hex="0102030405060708"))

for msg in client.get_bus_messages(ww_id=1, bus="can"):
    print(msg.can_id, msg.dlc, msg.bytes_hex)

msg = client.wait_for_bus_rx(ww_id=1, bus="can", match_hex="0102")
```

On CAN messages `bytes_hex` holds the frame's data bytes and the `can_id` /
`ext` / `fd` / `rtr` / `dlc` fields are populated; they are `None` for
uart/rs485.

### GPIO

```python
from wisentwire import GpioPullPolicy

shadow = client.get_gpio_shadow(ww_id=1)
client.set_gpio_output(ww_id=1, pin=0, is_high=True)
client.set_gpio_input_pull_policy(ww_id=1, pin=0, pull_policy=GpioPullPolicy.UP)

# Friendly pin labels
names = client.get_gpio_names(ww_id=1)
names.outputs[0] = "LED"
client.update_gpio_names(ww_id=1, names=names)

# Input telemetry
client.set_gpio_telemetry(ww_id=1, enabled=True)
for r in client.get_gpio_telemetry(ww_id=1):
    print(r.pin, r.high, r.timestamp_micros)
```

### Device registry

```python
from wisentwire import DeviceLabel

client.trigger_device_scan(ww_id=1)
connected = client.list_connected_devices(ww_id=1)

devices = client.list_registered_devices()          # optional label= filter
device = client.create_registered_device(
    name="bench PSU",
    label=DeviceLabel.POWER_SUPPLY,
    vendor_id="0483",
    product_id="5740",
    serial_short="A1B2C3",
)
client.delete_registered_device(device_id=device.id)
```

### Frame definitions

```python
frames = client.list_frame_definitions()
commands = client.list_commands(frame_def_id=frames[0].id)
```

### Reservations

`list_reservations` is scoped to one week — pass the week's start date:

```python
reservations = client.list_reservations(week_start="2026-06-15")
booking = client.create_reservation(
    ww_id=1,
    start_at="2026-06-20T09:00:00Z",
    end_at="2026-06-20T10:00:00Z",
)
client.delete_reservation(reservation_id=booking.id)
```

### Organization

```python
org = client.get_organization()
members = client.list_members()
client.create_invitation(email="new@company.com")
client.update_member_role(user_id=7, role="ADMIN")
```

### Account & system

```python
me = client.me()
print(me.email, me.organization)

version = client.get_version()
print(version.version, version.stage)   # confirm which backend you hit
```

## Error handling

Every API error raises a typed exception inheriting from `WisentWireError`:

```python
from wisentwire import NotFoundError

try:
    client.get_wisentwire(ww_id=999)
except NotFoundError as e:
    print(f"Wire not found: {e}")
```

| HTTP status | Exception                 |
|-------------|---------------------------|
| 400         | `BadRequestError`         |
| 401         | `AuthenticationError`     |
| 403         | `ForbiddenError`          |
| 404         | `NotFoundError`           |
| 409         | `ConflictError`           |
| 429         | `RateLimitError`          |
| 503         | `ServiceUnavailableError` |

Any other non-2xx status raises the base `WisentWireError`. Each exception
carries `.status` and the parsed ProblemDetail body as `.detail`.

The job pollers raise their own exceptions when a job reaches a terminal
failure status (`FAILED`, `TIMED_OUT`, `REJECTED`, `REMOVED`, `CANCELED`).
These signal device-side failure rather than an HTTP error, but they still
inherit from `WisentWireError`, so a single `except WisentWireError` catches
both transport and device-side failures:

```python
from wisentwire import FlashFailedError

try:
    client.wait_flash_complete(ww_id=1, job_id=dispatch.job_id, timeout=120)
except FlashFailedError as e:
    print(f"Flash failed on device: {e}")
```

`wait_debug_complete` raises `DebugFailedError` the same way. Every `wait_*`
helper takes a keyword-only `timeout` (seconds) and re-raises the last error
when it expires.

## Development

```bash
pip install -e ".[dev]"
pytest tests/ -v
```

## Requirements

- Python >= 3.10
- `requests` >= 2.28
- `tenacity` >= 8.0
