Skip to main content

Multi-protocol industrial communications library with a four-layer architecture (Client / Driver / Unit / Device) for VFDs, I/O modules, sensors, and motor controllers.

Project description

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 for the rename history.

Installation

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/IPEipSession 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 TCPModbusTcpSession 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 VFDsdrivers/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 moduledrivers/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 modulesdrivers/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:

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:

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

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

procaaso_field_device-0.0.1.tar.gz (235.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

procaaso_field_device-0.0.1-py3-none-any.whl (286.5 kB view details)

Uploaded Python 3

File details

Details for the file procaaso_field_device-0.0.1.tar.gz.

File metadata

  • Download URL: procaaso_field_device-0.0.1.tar.gz
  • Upload date:
  • Size: 235.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.3 CPython/3.12.10 Windows/11

File hashes

Hashes for procaaso_field_device-0.0.1.tar.gz
Algorithm Hash digest
SHA256 5e7510735c3f370955e560a2c05d1d77e0303e12135d5c901a8ec9703cb85239
MD5 c8ea861e7164d438fd04bc1a8bd7b443
BLAKE2b-256 e235a9b49d4afbe47263e2310bb58c2b14c83b3d4f8c84f2bc130590bf0caf91

See more details on using hashes here.

File details

Details for the file procaaso_field_device-0.0.1-py3-none-any.whl.

File metadata

File hashes

Hashes for procaaso_field_device-0.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5f210071248b2add6b2d646959de68e99486e4ee13951a707edb0361cd6f8311
MD5 c98d5d9f05c050d0e1ec1b8505c771de
BLAKE2b-256 f5f2f22eb665c9b257618accea7f68469142ab8ab14b56104d40dbad9ffc4460

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page