Metadata-Version: 2.4
Name: plcsai
Version: 2.0.0
Summary: Official Python SDK for the PLCs.ai API — interpret PLC code from your own tools.
Author: PLCs.ai
License: Proprietary
Project-URL: Homepage, https://developer.plcs.ai
Project-URL: Documentation, https://developer.plcs.ai
Keywords: plc,automation,rockwell,siemens,ai,plcs.ai
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"

# plcsai — Python SDK for the PLCs.ai API

Interpret PLC code from your own tools. Official Python client for the
[PLCs.ai API](https://developer.plcs.ai). Zero third-party dependencies.

Works with all three platform families the API supports: **Rockwell
Allen-Bradley** (`.L5X`), **Siemens TIA Portal** (`.zip` from the Desktop
Companion App) and **CODESYS V3** (`.export`, covering the OEM toolchains built
on it — WAGO, ABB, Schneider EcoStruxure Machine Expert and others). All three
read the same two ways: `get_source()` for the vendor-neutral parsed model, and
`download_source()` for the vendor file itself.

## Install

```bash
pip install plcsai
```

Requires Python 3.8+.

## Quickstart

```python
from plcsai import Client

client = Client(api_key="plck_live_…")  # or set PLCS_API_KEY

result = client.interpret(
    project_id="prj_…",
    prompt="Why is the filler at line 2 not advancing past Starting?",
)
if result.status == "answer":
    print(result.answer)
    for c in result.citations:          # where the answer was read from
        print(" -", c.location_kind, c.path)
print(result.request_id)  # quote this in a support request
```

### The assistant is interactive

Every AI call returns one of a few shapes, discriminated by `.status`. It can
stop and ask rather than guessing, so handling `needs_input` is part of using the
API — not an edge case:

```python
from plcsai import Answer

result = client.interpret("prj_…", "Why is the filler stuck?")

if result.status == "needs_input":
    for q in result.questions:
        print(q.question, q.options, q.why_it_matters)
        # q.recommended_index is the assistant's hint, or None. It is never
        # applied for you — 0 is a real index, so test for None, not falsiness.
    result = client.interpret(
        "prj_…",
        conversation_id=result.conversation_id,          # required with answers
        answers=[Answer(id="scope", selected_index=1)],  # or free_text="…"
    )

if result.status == "answer":
    print(result.answer)
elif result.status == "unresolved":
    print(result.reason)   # the prompt asked for a *change* — see generate()
```

`isinstance(result, AnswerResponse)` works too, and the `.status` literals let a
type checker narrow the union for you. A status this release does not recognize
raises `PlcsError`.

### Streaming

Prose arrives as `token` events. A turn that ends on questions, a plan, or a
refusal emits a single `outcome` event instead; `done` always carries the final
`status` and the whole turn's `usage`.

```python
for event in client.interpret_stream(project_id="prj_…", prompt="…"):
    if event.type == "token":
        print(event.text, end="", flush=True)
    elif event.type == "outcome":
        print("stopped early:", event.outcome.kind)   # questions | plan | changes | unresolved
    elif event.type == "done":
        print("\nstatus:", event.status, "usage:", event.usage)
```

The stream names two of these outcomes differently from the blocking body:
`outcome.kind` is `questions` where `status` is `needs_input`, and `changes`
where `status` is `code`.

### Conversations, analysis, embed tokens

```python
conv = client.create_conversation("prj_…", name="Line 2 stoppage")
msg = client.send_message(conv.conversation_id, "And why now?")
# Answers exactly as interpret() does, including needs_input:
if msg.status == "needs_input":
    client.send_message(conv.conversation_id, answers=[Answer(id="scope", free_text="line 2")])

# Nothing here produces an analysis as a side effect — you read what the app
# ran, and ask for a fresh one explicitly.
analysis = client.get_project_analysis("prj_…")
if analysis.status == "not_analyzed":
    # Nothing has run for THIS version and nothing will on its own — but an
    # older version may still have one. Read it deliberately, and label it:
    # `is_current_version` is False, so it describes code the project no longer
    # contains. Never report it as the project's current state.
    if analysis.last_analyzed_version_id:
        old = client.get_project_analysis(
            "prj_…", version_id=analysis.last_analyzed_version_id
        )
        print(old.version_id, old.is_current_version, analysis.last_analyzed_at)
elif analysis.status == "complete":
    print(analysis.results)
# Or block until it settles (returns on not_analyzed too, rather than hanging):
done = client.wait_for_project_analysis("prj_…")

# Stale or never analyzed? Run it explicitly (billable), then read again:
client.start_analysis("prj_…")

token = client.mint_embed_token("prj_…")  # read-only, for the iframe
```

### Projects: list, read source, live values

```python
page = client.list_projects(limit=50)
for p in page.projects:
    print(p.project_id, p.name, p.vendor, p.industry)  # industry: None = never analysed

detail = client.get_project("prj_…")          # metadata + analysis_status

model = client.get_source("prj_…").parsed     # the vendor-neutral parsed model (needs code_read)
raw_bytes = client.download_source("prj_…")   # the vendor file: L5X / ZIP / .export bytes

values = client.get_hmi_values("prj_…")          # needs hmi_view; live=False when no DCA session
history = client.get_hmi_history("prj_…", tag="Motor1.Speed")
```

### Exports (async): PLC file & PDF report

```python
job = client.export_plc("prj_…")                 # or client.export_pdf(...)
done = client.wait_for_export(job.export_id)
artifact = client.download_export(done.export_id)  # L5X / ZIP / .export / PDF bytes
open(done.filename, "wb").write(artifact)
```

### Save a new version (code_write)

```python
res = client.commit_version("prj_…", file_path="Conveyor_edited.L5X")
print(res.resolution, res.version_id)  # add_version, or identical_file (no-op)
print(res.analysis)                    # "not_analyzed" — a commit never analyzes
```

A commit is an edit, and analysis is expensive and per-version, so the new
version starts out unanalyzed. Graph and search indexing still run, so `ask(...)`
sees the change right away; call `start_analysis(...)` when you want the analysis
refreshed too.

The previous version's analysis is not lost, only superseded:
`get_project_analysis(...)` then reports `last_analyzed_version_id`, and passing
that as `version_id=` reads it back with `is_current_version = False`. Treat it
as a statement about code the project no longer contains — the commit may have
fixed a finding, or introduced one the run never saw.

### Propose a change (ai_generate — proposes, never deploys)

Authoring is **two turns**: the assistant proposes a plan, you approve it, and
only then is code written. Nothing is deployed even then — `approve_plan` creates
no version, so persist the result with `commit_version(...)` (`code_write`).

```python
proposal = client.generate("prj_…", "Add a 5-second start-up delay timer.")

if proposal.status == "plan":
    print(proposal.plan.summary)
    for step in proposal.plan.steps:
        print(step.target, step.intent)
    print(proposal.plan.assumptions)          # ambiguities it resolved — sanity-check these
    if proposal.plan.blocking_risks:          # severity == "block"
        raise SystemExit("needs a human")

    authored = client.approve_plan("prj_…", proposal.conversation_id)
    if authored.status == "code":
        print(authored.explanation)
        for change in authored.changes:
            print(change.action, change.target, change.code_type)
            print(change.content)
```

`generate` can also come back `needs_input` (answer and call again with
`conversation_id` + `answers`) or `unresolved`. `approve_plan` executes the plan
the server recorded when it proposed it, so what gets authored is exactly what
you reviewed; pass `amendment="use a latch instead of a seal-in"` to tweak it on
the way through. A plan can be approved once — a second approval is a `404`
rather than a second bill.

All three platforms are supported. On a CODESYS project a proposed change's
`code_type` is `"ST"` (the unit's complete Structured Text body),
`"Declaration"` (its complete declaration), `"LD"` (a rung-edit script rather
than source) or `"Task"` (a task-configuration change).

### Platform notes

- **CODESYS projects are uploaded, not connected.** Version-control connectors
  (GitHub, GitLab, Bitbucket, Copia, octoplant) link L5X and Siemens ZIP files
  only. A CODESYS project is uploaded in the app; `commit_version(...)` saves
  new versions of it — there is no sync-from-source path for `.export`.
- **Which verbs accept a CODESYS project** is published as a capability matrix at
  [developer.plcs.ai](https://developer.plcs.ai); a verb that doesn't refuses with
  `vendor_unsupported` (422) and names a supported path in `suggested_action`.
  Every verb currently accepts a CODESYS project.
- **CODESYS V2/2.3 exports are a different file format** and are rejected on
  upload with a message saying so.

## What the client handles for you

- **Auth** — sends `Authorization: Bearer …` on every request.
- **Idempotency** — auto-generates a stable `Idempotency-Key` per write (reused across retries).
- **Retries** — backs off and retries only on retryable errors, respecting `Retry-After`.
- **Streaming** — parses SSE into typed `StreamEvent`s.
- **`request_id`** — surfaced on every result.

## Errors

Non-2xx responses raise `plcsai.ApiError` with `.status_code`, `.error`,
`.user_message`, `.suggested_action`, `.is_retryable`, and `.request_id`.

## Versioning

```python
import plcsai
plcsai.__version__     # "2.0.0"      — this package
plcsai.API_CONTRACT    # "2026-09-02" — the API contract it was built against
```

`__version__` follows SemVer for this package. `API_CONTRACT` is the date of the
API contract the release targets; the `/api/v1` in the URL is a namespace and
does not change when the contract does. The two move independently.

The API contract is not frozen yet: it can change on a new date, and earlier
contracts are not served alongside it. `API_CONTRACT` tells you which one this
release speaks — if the API has moved past it, upgrade the package.
[developer.plcs.ai](https://developer.plcs.ai) publishes the current contract.

### Citations

`answer`, `plan` and `code` results carry `citations` — the locations the
assistant **read** while producing them. Provenance, not a relevance ranking: a
citation is recorded where the read happened, so every entry is a place the turn
genuinely looked at. Nothing about how it was chosen is on the wire.

```python
for c in result.citations:
    print(c.location_kind, c.path, c.rung, c.station)
```

`path` is the location as the platform spells it (`Main/Feed_Conveyor` on
Rockwell, `PLC_1/DriveStatus` on Siemens, `FB_Valve.Open` on CODESYS), so it can
be handed straight back to `get_source`. On a production line, `station` names
the station, and `project_id` is set only when the location is on a **sibling**
project rather than the one you asked about.

Two empties that are not errors: a turn that read nothing citable returns `[]`,
and so does any turn made with a key that lacks `code_read` — the answer is
unaffected, but a key that may not read your code is not handed the locations it
was read from. When per-kind quotas shorten the list, `citations_total` reports
how many were read; it is `None` when `citations` is already all of them.

On a streamed turn they arrive on the `done` event (`event.citations`), because
a turn whose answer streams as prose emits no `outcome` event at all.

## Upgrading from 1.x

The API contract changed on `2026-08-22` — `interpret` and `generate` returned
different shapes before it — and `2.0` follows it. The AI verbs now return a
union instead of a single shape:

| 1.x | 2.0 |
|---|---|
| `result.answer` | `result.answer` **after** checking `result.status == "answer"` |
| `result.citations` | `result.citations` — a `list[Citation]`, not the 1.x shape |
| `include_citations=…` | removed — citations are always returned, never requested |
| `proposal.generated_code` / `.code_blocks` | `generate()` returns a **plan**; `approve_plan()` then returns `.changes` |
| — | `needs_input` / `unresolved` are normal outcomes to handle |

The removed attributes raise `AttributeError`, so 1.x call sites surface as
errors rather than as empty values.
