Metadata-Version: 2.4
Name: wisent-wire-sdk
Version: 0.12.2
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 WISENT_WIRE_API_KEY and omit api_key
)

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

# Nothing passed at all — everything comes from the environment
client = WisentWireClient()
```

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 argument > environment, and across methods
`api_key` > `email` + `password`.

### Environment variables

Every setting can be supplied ambiently, so the same script runs unchanged
wherever it is pointed. All names use the `WISENT_WIRE_*` spelling.

| Variable | Purpose |
|---|---|
| `WISENT_WIRE_API_KEY` | API key (`wwk_…`). Recommended. |
| `WISENT_WIRE_EMAIL` + `WISENT_WIRE_PASSWORD` | Cognito login. Both are required. |
| `WISENT_WIRE_URL` | API base URL. Omit it to target production. |

`WISENTWIRE_API_KEY` (no underscore) is still read as a **deprecated** alias and
warns on use — it is the one no-underscore name with released history.

`WISENT_WIRE_ENV`, `WISENTWIRE_ENV` and `WISENTWIRE_URL` are gone, and setting
any of them now **raises** rather than being ignored. Ignoring them would not
fail: the client would fall back to production, so a stale export would quietly
send an int credential at prod. Use `WISENT_WIRE_URL` with the full address.

An exported-but-empty variable counts as unset, so a stray `export
WISENT_WIRE_API_KEY=` in a shell profile falls through to the next method
rather than sending an empty credential.

The MCP server reads nothing of its own — it hands the whole job to this
client, so one exported environment configures both packages identically.

### Choosing a backend

`base_url` is optional and defaults to production. Give it an address to target
anything else:

```python
client = WisentWireClient("https://int.wisent-wire.com", api_key="wwk_...")
client = WisentWireClient("http://localhost:7998", api_key="wwk_...")
```

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

```bash
export WISENT_WIRE_URL=https://int.wisent-wire.com
```

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

**The target is an address, never a name.** There was a named-environment form
(`env="int"`, `WISENT_WIRE_ENV`) and it is gone. The abstraction could not reach
the case it most needed to cover — a local backend has a per-developer port, so
it can never be one of the names — which meant an address form had to exist
regardless. Two ways of saying the same thing is a second thing to keep in sync,
and it was the second thing that drifted.

## 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

A wisent wire can carry more than one supply, so every call names which one by
its registered-device tag.

```python
# Which supplies will accept a write, and what each reports
for psu in client.list_power_supplies(ww_id=1):
    print(psu.tag, psu.name)
tag = client.power_supply_tags(ww_id=1)[0]

client.set_power_supply(1, tag, state="ON", voltage=5.0, current=2.0)
entry = client.wait_shadow_state(1, tag, "ON")
print(entry.reported.voltage_setpoint)

client.set_power_supply_telemetry(ww_id=1, enabled=True)
readings = client.get_power_telemetry(ww_id=1)   # {tag: [points]}
```

Take the tag from `list_power_supplies` / `power_supply_tags`, not from
`get_shadow`. They answer different questions: the backend accepts a write only
for a supply the device registry says is plugged in, while the shadow keeps
reporting one after it has been unplugged — so a tag read from the shadow can
look fine and be refused by every write with `404 No power supply <tag> is
plugged into this wisent wire`.

`get_shadow` is still how you read state, and on a wire with exactly one supply
`.only()` hands back `(tag, entry)` without you having to know its name:

```python
tag, entry = client.get_shadow(ww_id=1).only()   # raises, naming the tags, if not exactly one
print(entry.reported.state)
```

### 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
