Metadata-Version: 2.3
Name: procaaso-field-device
Version: 0.0.1
Summary: Multi-protocol industrial communications library with a four-layer architecture (Client / Driver / Unit / Device) for VFDs, I/O modules, sensors, and motor controllers.
License: MIT
Keywords: ethernetip,eip,modbus,modbus-tcp,industrial-automation,cip,vfd,motor-control,powerflex,moxa,iologik,io-modules
Author: ProCaaSo
Requires-Python: >=3.11,<4.0
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Manufacturing
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Classifier: Topic :: System :: Networking
Project-URL: Homepage, https://github.com/ConSynSys-Automation/procaaso-field-device
Project-URL: Repository, https://github.com/ConSynSys-Automation/procaaso-field-device
Description-Content-Type: text/markdown

# procaaso-field-device

Industrial-automation communication library for VFDs, motor controllers,
I/O modules, and sensors. The active codebase is a layered rework whose
design philosophy is documented below — read it before adding a new
device, a new protocol, or a new unit archetype.

> **Note.** This library was previously published on PyPI as
> `procaaso-eip`. It has been renamed to **`procaaso-field-device`** to
> reflect its scope as a general multi-protocol hardware-interface
> library rather than an EtherNet/IP–only package. See
> [CHANGELOG.md](CHANGELOG.md) for the rename history.

## Installation

```bash
pip install procaaso-field-device
```

Requires Python 3.11+.

## Design philosophy — the four layers

Every piece of code in this repo belongs to exactly one of four layers.
Each layer has a single responsibility and a single direction of
dependency: **upper layers depend on lower layers; lower layers know
nothing about upper layers.** Crossing that rule is the most common way
to make this library hard to maintain.

```
                     ┌──────────────────────────────┐
                     │   Device  (planned)          │  Scan-loop orchestration
                     │   composes many Units        │  across many Units
                     └──────────────┬───────────────┘
                                    │ commands
                     ┌──────────────┴───────────────┐
                     │   Unit  (archetype)          │  MotorUnit, IOUnit, SensorUnit, ...
                     │   stable abstract command    │  Per-archetype command vocabulary
                     │   vocabulary + dispatch      │  shared by every vendor's driver
                     └──────────────┬───────────────┘
                                    │ getattr(driver, mapped_name)
                     ┌──────────────┴───────────────┐
                     │   Driver  (hardware)         │  PowerFlex753Driver, NanotecDriver, ...
                     │   vendor wire format         │  One class per physical device family
                     │   + unit method_map manifest │
                     └──────────────┬───────────────┘
                                    │ uses
                     ┌──────────────┴───────────────┐
                     │   Client  (protocol)         │  EipSession, ModbusSession, ...
                     │   device-agnostic transport  │  One instance can serve many drivers
                     └──────────────────────────────┘
```

### 1. Client layer — the protocol

A Client is **device-agnostic**. It implements a communication protocol
(EtherNet/IP, Modbus TCP, CANopen, etc.) and exposes a session object
that drivers can borrow. A Client MUST NOT contain any logic specific to
a particular vendor's device. If a piece of code only makes sense when
talking to a PowerFlex, it does not belong in the Client.

A Client is always its own object with its own lifecycle (`connect`,
`disconnect`, request/reply primitives). The rest of the library is
designed around the idea that **one Client instance can be passed into
many Drivers** — multiple devices sharing one TCP session on a chassis,
for example. The Client's only contract is "can the Driver hand me a
request and get a reply back."

Today's reference implementations:

- **EtherNet/IP** — `EipSession` at
  `src/procaaso_field_device/clients/ethernet_ip/`. Covers the
  EtherNet/IP encapsulation header, the Common Packet Format body, CIP
  messaging, Forward_Open / Forward_Close, and the explicit
  request/reply round-trip.
- **Modbus TCP** — `ModbusTcpSession` at
  `src/procaaso_field_device/clients/modbus_tcp/`. Covers the MBAP
  header, function-code framing, and the request/reply round-trip.

Protocol-level facts for each client live in a `PROTOCOL_NOTES.md`
next to the implementation (see
`clients/ethernet_ip/PROTOCOL_NOTES.md` and
`clients/modbus_tcp/PROTOCOL_NOTES.md`).

Each Client lives under `src/procaaso_field_device/clients/<protocol>/` and is
**never coupled to a specific Driver**. The Client and the Drivers
that use it are in sibling folders, not nested — the Driver borrows
the Client through dependency injection, never the other way around.

### 2. Driver layer — the hardware

A Driver is **hardware-specific**. One Driver class per physical device
family (PowerFlex 753, Nanotec C5-E, Moxa E1212, etc.). It encodes:

- Vendor-specific wire-format details (CIP class/instance/attribute
  numbers, parameter-instance math, bit-position tables, encoding
  asymmetries).
- The set of read/write methods that exercise those details
  (`read_logic_status_word`, `write_speed_reference`,
  `read_motor_poles`, ...).
- A **list of compatible Clients** — today enforced by a typed
  parameter on `__init__` (`if not isinstance(session, EipSession):
  raise DriverError(...)`); as more protocols come online this will
  generalize to an explicit allowlist.
- **Unit method-map manifests** — one class attribute per unit
  archetype the driver can serve (see §3 and the dedicated section
  below).

A Driver takes its Client through the constructor; it never reaches out
to construct one. That keeps the Client/Driver coupling one-way and
preserves the "one Client serves many Drivers" property.

Drivers live under `src/procaaso_field_device/drivers/<category>/<vendor>/<model>/`.
The category mirrors the unit archetype the driver serves (`vsd/` for
variable-speed drives, `io/` for I/O modules; future `sensor/` /
`position/` folders for those archetypes).

Reference implementations today:

- **PowerFlex 750-series VFDs** —
  `drivers/vsd/powerflex/{base,pf753,pf755}/`. The `base/` package
  holds shared 750-series logic and the motor-unit method-map manifest
  (`base/unit_config.py`); `pf753/` and `pf755/` carry the
  model-specific config, constants, and driver classes.
- **PowerFlex Series 22 I/O option module** —
  `drivers/vsd/powerflex/series_22_io_option_module/`. Add-on I/O for
  the 750-series chassis; serves the IO unit archetype with its own
  method-map.
- **Moxa E1200 I/O modules** —
  `drivers/io/moxa/e1200/`. Family-style driver covering the E1210,
  E1211, E1212, E1213, E1214, E1240, E1241, E1242, E1260, and E1262.

Every non-trivial decision in a driver points to an anchor in a
sibling `DRIVER_NOTES.md` (one per model package), so an AI agent
walking the code can read the justification without inflating the
source. The PF755 integration story (where it diverges from PF753) is
captured in `drivers/vsd/powerflex/PF755_INTEGRATION.md`.

### 3. Unit layer — the archetype

A Unit is **archetype-agnostic** within a category. Every Driver that
controls a motor is interacted with through the **same** abstract
command vocabulary — `command_set_setpoint`, `command_start`,
`command_stop`, `command_forward`, `command_reverse`,
`command_clear_fault`. Every Driver that exposes I/O channels will
eventually go through an `IOUnit` with its own vocabulary, and so on
for `SensorUnit`, `PositionUnit`, and future archetypes.

The Unit is the layer where modularity becomes real:

- **A scan loop, an HMI, or a control block calls `unit.command_X(...)`
  and gets the same behavior regardless of which vendor sits
  underneath.** Swapping a PowerFlex for a Yaskawa or a Nanotec is a
  driver-side concern; the Unit-level call sites do not change.
- **The Unit forwards the call to the Driver via a method-map** that
  the Driver publishes as a class attribute. The Unit has no knowledge
  of vendor method names. See the next section for the full contract.
- **Driver methods that aren't in the abstract vocabulary still bubble
  up through `unit.drv`.** Anything PowerFlex-specific (fault-queue
  introspection, IO option-card writes, nameplate parameter reads,
  Forward_Open lifecycle) is still callable as `unit.drv.<method>()`.
  The Unit narrows the *common* surface without hiding the rest.

Naming: the existing implementations are `MotorUnit` and `IOUnit`, but
the pattern is **not archetype-specific**. Future units (`SensorUnit`,
`PositionUnit`, ...) follow the same construction: a per-archetype
command vocabulary, a driver-side method-map under a conventional
attribute name (`<archetype>_unit_method_map`), and the same
`.drv` escape hatch. When you add a new archetype, the design pattern
already exists — copy `MotorUnit`'s or `IOUnit`'s structure, change
the vocabulary, and ship. See
`docs/unit/adding-a-new-unit-archetype.md` for the step-by-step.

Unit-level contracts are documented end-to-end in
`src/procaaso_field_device/units/motor/UNIT_NOTES.md` and
`src/procaaso_field_device/units/io/UNIT_NOTES.md`. The PowerFlex
750-series driver-side manifest is at
`src/procaaso_field_device/drivers/vsd/powerflex/base/unit_config.py`.

### 4. Device layer — orchestration (planned)

A Device composes multiple Units into a coherent piece of plant
equipment with its own execution loop — a pump skid with a motor unit,
an IO unit reading flow / pressure, and a sensor unit reading
temperature, all stepping in lock-step on a single scan cycle.

This layer is **planned but not yet implemented.** The folder is
checked in as `src/procaaso_field_device/device/` with a README describing
its anticipated structure. New Driver and Unit
work should be designed with this layer in mind — most importantly,
keeping per-call latency under the 5 ms scan budget the rework targets.

## The driver↔unit method-map contract

This is the central mechanism that makes the Unit layer work. If you
are adding a new driver, this is the contract you MUST satisfy.

### What the driver publishes

For every Unit archetype the Driver can serve, the Driver declares a
class attribute named `<archetype>_unit_method_map` whose value is a
`dict[str, str]` mapping abstract command names to concrete driver
method names:

```python
class PowerFlex753Driver:
    motor_unit_method_map = {
        # Required on every motor driver.
        "command_set_setpoint": "write_speed_reference",
        "command_start":        "write_command_start",
        "command_stop":         "write_command_stop",
        # Optional — PowerFlex supports these, so they're mapped.
        "command_forward":      "write_command_forward",
        "command_reverse":      "write_command_reverse",
        "command_clear_fault":  "write_command_clear_fault",
    }
```

The attribute name is a **convention**, not an import. The Driver does
NOT import anything from the Unit package. The Unit knows to look for
`motor_unit_method_map` (or `io_unit_method_map`, etc.) on whatever
driver it is handed. This is what keeps the dependency direction
one-way: Units know about Drivers; Drivers do not know about Units.

A single driver class can publish multiple method-map attributes — one
per unit archetype it can serve. A combined VFD+IO driver, for example,
would declare both a `motor_unit_method_map` and an
`io_unit_method_map`.

### What the unit does at construction

`MotorUnit.__init__` (and every future archetype unit's `__init__`)
performs a **boot-time** validation pass:

1. The driver has the expected method-map attribute.
2. The attribute is a `dict`.
3. Every entry in `REQUIRED_COMMANDS` for that archetype is present in
   the map.
4. Every mapped method name actually resolves to a callable on the
   driver.

A misconfigured pairing fails immediately at construction with a
`ConfigurationError`, never silently at first call. If
`MotorUnit(config, driver=X)` returns, driver `X` is contract-compliant
at the structural level for that archetype.

### What the unit does at runtime

Every `unit.command_X(...)` call walks through `_dispatch`, which:

1. Looks up `command_X` in the driver's method-map.
2. If absent (an *optional* command the driver didn't map), raises
   `UnsupportedCommandError` with the list of supported commands.
3. If present, calls
   `getattr(self._driver, mapped_name)(*args, **kwargs)` and returns
   whatever the driver method returned.

No allocation. No serialization. No transformation of arguments. The
Unit is a thin dispatcher; the wire-level work lives in the driver
method.

### The escape hatch — `unit.drv`

Every Unit exposes `unit.drv`, which returns the bound driver. Use it
for any driver method that the abstract vocabulary deliberately doesn't
cover:

```python
unit = MotorUnit(config, driver=powerflex)

unit.command_start()                      # abstract, vendor-neutral
unit.command_set_setpoint(50.0)           # abstract, vendor-neutral

# Vendor-specific — bubble up through the escape hatch:
faults = unit.drv.read_fault_queue_count()
record = unit.drv.read_fault(1)
unit.drv.write_io_analog_output_value(channel=0, value=4.0)
```

This keeps the abstract surface intentionally small (only the things
that generalize across every vendor of that archetype), while still
giving callers full access to the driver's specialised surface when
they need it. A `unit.drv.X()` call site is a deliberate, visible
admission that the caller is reaching past the abstraction; it is
**not** a workaround for an incomplete map.

### Recipe: onboarding a new driver

1. Implement the driver class in
   `src/procaaso_field_device/drivers/<protocol>/<vendor>/driver.py`.
   Whatever method names fit the vendor's manuals are fine; the Unit
   doesn't care.
2. Declare the method-map(s) as plain `dict[str, str]` constants in a
   sibling `unit_config.py` next to the driver.
3. In the driver class body, alias each constant to its conventional
   attribute name (`motor_unit_method_map = MOTOR_UNIT_METHOD_MAP`).
4. Optionally write a `DRIVER_NOTES.md` next to the driver capturing
   the vendor-specific quirks worth remembering.
5. Construct the matching Unit against the driver:
   `MotorUnit(MotorUnitConfig(...), driver=YourDriver(...))`. If
   construction succeeds, the contract is satisfied.

The unit-side code does not change. Every existing call site for that
archetype continues to work.

## Repository orientation

| Path                                                              | Status              | What lives there                                                                          |
| ----------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------- |
| `src/procaaso_field_device/clients/<protocol>/`                   | Client (Layer 1)    | One folder per communication protocol. Today: `ethernet_ip/`, `modbus_tcp/`.              |
| `src/procaaso_field_device/drivers/<category>/<vendor>/<model>/`  | Driver (Layer 2)    | One folder per device model, grouped by vendor and category. Today: `vsd/powerflex/{base,pf753,pf755,series_22_io_option_module}/`, `io/moxa/e1200/`. |
| `src/procaaso_field_device/units/<archetype>/`                    | Unit (Layer 3)      | One folder per unit archetype. Today: `motor/`, `io/`.                                    |
| `src/procaaso_field_device/device/`                               | Device (Layer 4)    | Planned orchestration layer. Empty placeholder with a README; not implemented yet.        |
| `src/procaaso_field_device/common/`                               | Shared              | `SignalValue`, `StatusCode`, scaling, filtering, safety, exceptions, diagnostics, modes. Used by every layer. |
| `docs/`                                                           | Design              | `library-modules-reference.md` (design guide), `notes-index.md` (anchor catalog), `setup-and-conventions.md`, and per-layer READMEs under `client/`, `driver/`, `unit/`, `device/`. |
| `examples/`                                                       | Demos               | Bench-shaped pytest scripts exercising the library against real hardware (PF753, PF755, Series 22 IO, Moxa E1200). |
| `tests/`                                                          | Tests               | `pytest` mirror of `src/procaaso_field_device/` plus driver-contract conformance suites under `tests/contracts/`. |
| `tools/`                                                          | Tools               | Static-analysis helpers (`safety_analyzer.py`, `check_slots.py`) and `gen_notes_index.py`.|
| `manuals/`                                                        | Reference           | Vendor PDFs and Markdown extractions cited by NOTES files.                                |
| `legacy/`                                                         | Archive             | Reserved for any pre-rework code preserved for reference. Currently empty.                |

### Where to read more

- `docs/library-modules-reference.md` — design guide; the source for
  the architectural decisions in this README.
- `docs/setup-and-conventions.md` — repo-level setup, layered
  architecture summary, and status-propagation contract.
- `docs/unit/extending-unit-commands.md` — procedural guide for adding
  new REQUIRED or OPTIONAL commands to any unit archetype; documents
  the full blast radius (code + docs + tests).
- `docs/unit/adding-a-new-unit-archetype.md` — step-by-step for
  introducing a brand-new unit archetype alongside `motor/` and `io/`.
- `docs/notes-index.md` — generated grep-friendly catalog of every
  anchor across the NOTES files. Regenerate with
  `python3 tools/gen_notes_index.py > docs/notes-index.md`.
- `src/procaaso_field_device/clients/ethernet_ip/PROTOCOL_NOTES.md` —
  wire-format conventions and protocol-level quirks for EtherNet/IP.
- `src/procaaso_field_device/clients/modbus_tcp/PROTOCOL_NOTES.md` —
  wire-format conventions for Modbus TCP.
- `src/procaaso_field_device/drivers/vsd/powerflex/base/DRIVER_NOTES.md`
  — PowerFlex 750-series shared driver notes.
- `src/procaaso_field_device/drivers/vsd/powerflex/pf753/DRIVER_NOTES.md`
  and `.../pf755/DRIVER_NOTES.md` — model-specific quirks.
- `src/procaaso_field_device/drivers/vsd/powerflex/PF755_INTEGRATION.md`
  — PF755 vs PF753 integration delta.
- `src/procaaso_field_device/drivers/io/moxa/e1200/DRIVER_NOTES.md` —
  Moxa E1200 family driver notes.
- `src/procaaso_field_device/units/motor/UNIT_NOTES.md` — motor-unit
  driver contract in full.
- `src/procaaso_field_device/units/io/UNIT_NOTES.md` — IO-unit driver
  contract in full.
- `src/procaaso_field_device/device/README.md` — placeholder describing
  the planned Device layer (Layer 4) and its anticipated structure.

## Supported hardware

| Device                                       | Unit archetype    | Client                              | Driver location                                                              |
| -------------------------------------------- | ----------------- | ----------------------------------- | ---------------------------------------------------------------------------- |
| Allen-Bradley PowerFlex 753                  | `MotorUnit`       | EtherNet/IP (`EipSession`)          | `drivers/vsd/powerflex/pf753/`                                               |
| Allen-Bradley PowerFlex 755                  | `MotorUnit`       | EtherNet/IP (`EipSession`)          | `drivers/vsd/powerflex/pf755/`                                               |
| Allen-Bradley Series 22 I/O option module    | `IOUnit`          | EtherNet/IP (`EipSession`)          | `drivers/vsd/powerflex/series_22_io_option_module/`                          |
| Moxa ioLogik E1210 / E1211 / E1212 / E1213 / E1214 | `IOUnit`    | Modbus TCP (`ModbusTcpSession`)     | `drivers/io/moxa/e1200/`                                                     |
| Moxa ioLogik E1240 / E1241 / E1242           | `IOUnit`          | Modbus TCP (`ModbusTcpSession`)     | `drivers/io/moxa/e1200/`                                                     |
| Moxa ioLogik E1260 / E1262                   | `IOUnit`          | Modbus TCP (`ModbusTcpSession`)     | `drivers/io/moxa/e1200/`                                                     |

## Quick example

```python
from procaaso_field_device.clients.ethernet_ip.client import EipSession, EipSessionConfig
from procaaso_field_device.drivers.vsd.powerflex.pf753.config import PowerFlex753Config
from procaaso_field_device.drivers.vsd.powerflex.pf753.driver import PowerFlex753Driver
from procaaso_field_device.units.motor import MotorUnit, MotorUnitConfig

# Client — protocol only, no device knowledge.
session = EipSession(EipSessionConfig(host="192.168.1.10"))
session.connect()

# Driver — borrows the Client, encodes vendor-specific wire format.
drive = PowerFlex753Driver(session, PowerFlex753Config())

# Unit — abstract motor vocabulary, vendor-neutral call sites.
motor = MotorUnit(MotorUnitConfig(), driver=drive)

motor.command_start()
motor.command_set_setpoint(50.0)        # Hz, abstract
# ...
motor.command_stop()

# Escape hatch for driver-specific reads:
faults = motor.drv.read_fault_queue_count()

session.disconnect()
```

For a runnable bench-test version of this flow, see
`examples/motor_unit_test_pf753.py`.
