2026-03-02 17:48:55 -05:00
2026-03-03 17:25:38 -05:00
2026-03-02 17:48:55 -05:00
2026-03-02 17:48:55 -05:00
2026-03-02 17:48:55 -05:00
2026-03-02 17:48:55 -05:00
2026-03-03 17:25:38 -05:00
2026-03-02 17:48:55 -05:00
2026-03-02 17:48:55 -05:00
2026-03-02 17:48:55 -05:00

Arnold — Terminator I/O Server

Fast-poll Modbus TCP server for AutomationDirect Terminator I/O (T1H-EBC100). Digital and analog I/O, REST API, timed output sequences, web UI, TUI debugger.

Quick start

pip3 install pymodbus fastapi uvicorn pyyaml textual --break-system-packages

# REST API + web UI
python3 server.py --config config_with_outputs.yaml

# TUI debugger (standalone, no API server)
python3 tui.py config_with_outputs.yaml

Web UI: http://<host>:8000 API docs: http://<host>:8000/docs

Web UI

Three-panel layout (mirrors the TUI). Polls I/O at ~20 Hz.

  • Inputs — live digital (dot indicators) and analog (raw integer) values
  • Outputs — click to toggle digital; +/- and Write for analog (065535)
  • Sequences — run button, live step list with progress bar

Responsive: 3-column on desktop, stacked on tablet/phone.

TUI

Terminal debugger with the same three panels.

Key Action
Tab Cycle focus: Outputs / Sequences
Space/Enter Toggle digital output / Run sequence
+/- Adjust analog output (step 100)
Enter Commit analog value
0 / 1 All digital outputs OFF / ON
r Reconnect all devices
q Quit

API

Method Path Description
GET /status Device health, poll rates, active run
GET /io All signal states
GET /io/{signal} Single signal + metadata (category, value_type, modbus_space)
POST /io/{signal}/write Write output: {"value": true} or {"value": 4000}
GET /config/signals Full signal config list (for UI bootstrap)
GET /sequences List sequences
GET /sequences/{name} Sequence detail with steps
POST /sequences/{name}/run Start sequence (202), 409 if busy
GET /runs/{run_id} Run result
GET /runs Recent run history

Config

devices:
  - id: ebc100_main
    host: 192.168.3.202
    port: 502
    unit_id: 1
    poll_interval_ms: 50
    modules:
      - { slot: 1, type: T1H-08TDS }    # 8-pt digital input
      - { slot: 2, type: T1H-08AD-1 }   # 8-ch analog input (12-bit)
      - { slot: 3, type: T1K-16TD2-1 }  # 16-pt digital output
      - { slot: 4, type: T1H-04DA-1 }   # 4-ch analog output (12-bit)

logical_io:
  - { name: sensor_a,  device: ebc100_main, slot: 1, point: 1, direction: input }
  - { name: pressure,  device: ebc100_main, slot: 2, point: 1, direction: input }
  - { name: valve_1,   device: ebc100_main, slot: 3, point: 1, direction: output, default_state: false }
  - { name: dac_ch1,   device: ebc100_main, slot: 4, point: 1, direction: output, default_value: 2048 }

sequences:
  - name: actuate
    description: "Open valve, set DAC, verify sensor"
    steps:
      - { t_ms: 0,    action: set_output,  signal: valve_1,  state: true }
      - { t_ms: 100,  action: set_output,  signal: dac_ch1,  value: 4000 }
      - { t_ms: 500,  action: check_input, signal: sensor_a, expected: true }
      - { t_ms: 600,  action: check_input, signal: pressure, expected_value: 2000, tolerance: 100 }
      - { t_ms: 1000, action: set_output,  signal: valve_1,  state: false }

t_ms is absolute from T=0 (not relative). Steps auto-sort by t_ms. Failed check_input aborts immediately — remaining steps are skipped. wait_input blocks until condition met or timeout_ms expires.

Supported modules (44 types)

Category Examples Address space
Digital input T1H-08TDS, T1H-16ND3, T1K-08ND3, ... coil (FC02)
Digital output T1H-08TD1, T1K-16TD2-1, T1H-08TA, ... coil (FC05/FC15)
Relay output T1H-08TRS, T1H-16TRS2, T1K-08TRS coil (FC05/FC15)
Analog input T1H-08AD-1 (12-bit), T1H-16AD-2 (15-bit), ... register (FC04)
Analog output T1H-04DA-1 (12-bit), T1H-08DA-2 (15-bit), ... register (FC06/FC16)
Temperature input T1H-08THM, T1H-04RTD, T1K-08THM, T1K-04RTD register (FC04)

Full registry in arnold/module_types.py.


Architecture

server.py                    Entrypoint: config load, poll start, default writes, uvicorn
tui.py                       Textual TUI debugger (standalone, no API server)
probe_terminator.py          Standalone Modbus probe script (not part of package)

config.yaml                  Input-only hardware config
config_with_outputs.yaml     Input + output hardware config
runs.log                     JSON-lines run history (created at runtime)

arnold/
  __init__.py
  module_types.py            ModuleType frozen dataclass + 44-module registry
  config.py                  YAML loader, validation, dual address space computation
  terminator_io.py           Modbus TCP driver (dual-connection), signal cache, poll thread
  sequencer.py               Sequence engine: timing, digital+analog set/check/wait
  api.py                     FastAPI app: REST endpoints, static file mount for web UI

web/
  index.html                 Single-page app shell
  style.css                  Dark theme, responsive (desktop/tablet/phone)
  app.js                     Vanilla JS: polling, output control, sequence runner

Data flow

YAML config ──► config.py ──► module_types.py (resolve part numbers)
                   │
                   ▼
               terminator_io.py
              ┌─ TerminatorIO (two TCP connections per device)
              │  ┌─ _reader conn ── FC02 read discrete inputs (digital)
              │  │                  FC04 read input registers (analog)
              │  └─ _writer conn ── FC05/FC06 write single coil/register
              │                     FC15/FC16 write multiple coils/registers
              └─ _PollThread (daemon, reads via _reader each cycle)
              └─ IORegistry (multi-device coordinator, signal cache)
                   │
          ┌────────┼────────┐
          ▼        ▼        ▼
       api.py   sequencer  tui.py / web UI

Dual address spaces

The EBC100 has two independent flat address spaces:

  • Coil space (1-bit) — digital modules. FC01/FC02/FC05/FC15. Sequential by slot.
  • Register space (16-bit) — analog/temperature modules. FC03/FC04/FC06/FC16. Sequential by slot.

A digital module advances only coil_offset. An analog module advances only register_offset. They do not interfere. config.py computes all addresses at load time.

Dual-connection architecture

Each TerminatorIO opens two independent TCP connections to the EBC100: a reader (used exclusively by the poll thread for FC02/FC04) and a writer (used by sequencer/API/TUI for FC05/FC06/FC15/FC16). Each has its own lock and reconnect state. Writes never block behind poll reads, reducing typical output actuation jitter from 535 ms to 519 ms.

EBC100 quirks

  • Returns zeros for out-of-range reads (no Modbus exception code 2)
  • FC05 echoes True for any address, even unmapped — no write error feedback
  • Responds to any unit ID (echoed, not routed). Use unit_id: 1
  • No unsolicited push — polling mandatory
  • UDP 502 inactive; ports 443, 503, 8080 closed
Description
Sequencer for TerminatorIO
Readme 182 KiB
Languages
Python 82.2%
JavaScript 11.6%
CSS 4.9%
HTML 1.3%