Industrial-AIOps
English · 中文
Ask an AI agent why the line stopped — and get an answer that cites its evidence.
A vendor-neutral, read-first data tap for the factory floor. It speaks 15 field protocols, correlates what it reads across them, and hands your agent an evidence-cited verdict instead of a guess. Every call is audited, and no reading ever phones home.
pip install "iaiops[opcua]" # pick your protocol — or [all]
iaiops init # write ~/.iaiops/config.yaml
iaiops doctor # check the setup before you trust it
Prefer a container? The published image is cosign-signed and runs non-root. It speaks MCP over stdio, so keep stdin open and mount a volume for the audit store:
cosign verify --key deploy/margo/cosign.pub ghcr.io/industrial-aiops/iaiops:0.31.0-factory
docker run -i --rm -v iaiops-state:/home/iaiops/.iaiops \
ghcr.io/industrial-aiops/iaiops:0.31.0-factory
For a hardened or air-gapped deployment (read-only rootfs, cap_drop: ALL, no-new-privileges,
optional on-box LLM) use deploy/margo/compose.yaml and
deploy/airgap/. The analysis engine needs no GPU and no model API — it is
deterministic; an LLM is optional and only phrases the verdict.
What you get
| Reads | OPC-UA (+ Historical Access, tag auto-discovery) · Modbus TCP/RTU · S7comm · Mitsubishi MC · Omron FINS · MTConnect · MQTT/Sparkplug B · EtherNet/IP · EtherCAT · PROFINET · SECS/GEM · HART-IP · BACnet/IP · IO-Link · serial ASCII instruments (RS-232/485, serial device servers) — plus read-only REST layers for BAS supervisors (Metasys / Niagara) and Ignition Gateway |
| Figures out | downtime root cause (the flagship copilot), alarm floods (ISA-18.2), broken dataflows, data trustworthiness, OEE, asset inventory, legacy PLC program explainer (ST/AWL/L5X) |
| Governs | audit · budget · risk-tier · undo — on every call, through one engine, from both MCP and CLI |
| Stays yours | no telemetry, no phone-home. Six tools can send data off-box by design (stream_publish, stream_publish_event, uns_publish, historian_push, mqtt_publish, rca_narrate) — IAIOPS_NO_EGRESS=1 withholds all six for an air-gapped posture |
Eleven editions ship in this package — ten per industry (fab · factory · process · building · water ·
warehouse · clinical · pharma · renewables · plcnext), each adding its own read-only advisory checks,
and one cross-protocol: sensor (connect, decode and read a sensor; IAIOPS_MCP=sensor).
Substation / utility telecontrol (IEC-104 · DNP3 · IEC-61850) ships separately as
iaiops-energy.
Your first five minutes
Four commands. Only one of them touches a device: doctor probes each configured
endpoint (iaiops doctor --skip-probe contacts nothing).
pip install "iaiops[modbus]" # pick the protocol you actually have, or [all]
iaiops onboard status # ← run this first. Contacts NOTHING.
iaiops doctor # config, secrets, reachability — and the version
iaiops readiness # every scenario, and what each gap needs
onboard status answers the smaller questions you have first: which journey is
this site on — reading its devices directly, or subscribing to a UNS broker its
data already flows through — which step of it are you on, and what is the one
command that advances it? It is derived from your store and config.yaml every
time. The one thing it keeps is a namespace audit verdict, and that counts only for
the broker and topic filter it was taken on and always shows its age — so edit
config.yaml by hand and the answer stays true.
readiness reads your config and local store and answers one question: which
scenarios can this site run today, and what does each gap need? Every gap comes
with the command that closes it, ranked by how much it unlocks. No agent, no
cloud, no account, and nothing on the wire.
Then the path, in the order that matters — survey what is there, take a bounded sample, and only then explain it:
| contacts a device? | ||
|---|---|---|
| Survey | iaiops scan plan → iaiops scan run | preview sends nothing; the run itemises every packet class it sent |
| Configure | iaiops onboard draft → you merge it into config.yaml | no — it reads the stored scan, and writes nothing |
| Tap | iaiops collect run line1 --duration 7d | yes — and it reports what it saw and what it missed |
| Declare | iaiops tags export → a person fills in role → iaiops tags apply --by <you> | no — the role column comes out empty on purpose |
| Explain | iaiops oee measure --since … --until … · iaiops investigate open · iaiops diag rca | no — all over collected history |
See the whole thing run against a real device in about two minutes, including
a genuine mid-run outage, with ./demo/oee-line/run_demo.sh — no hardware, no
configuration, nothing written outside a temporary directory.
demo/oee-line/ explains what each step is for and what the
numbers do and do not claim.
Why read-first
OT is exactly where you want an agent on a tight leash. The read paths are the product; the few write paths are OT-dangerous, off by default, and gated by MOC discipline — dry-run, one-shot approval, undo capture, hash-chained audit.
Proving the analysis needs no model
The analysis layers cannot reach a language model. That is a guard, not a slogan:
tests/test_brain_is_llm_free.py scans eight packages — brain, discovery, runtime,
readiness, collect, knowledge, retain, connectors — for any import that could reach one,
and an empty result is the guarantee. A model is used in exactly two places, and neither is
load-bearing: rca_narrate rephrases a verdict that was already computed and already cited, and an
agent front-end decides which tool to call. Remove both and the numbers are the same numbers.
That guard is static — it proves nothing can call a model. For a validation team the sentence they are asked to accept is the executed one, so it is executed:
iaiops verify determinism --out determinism-record.json
A pinned in-repo dataset goes through availability, production counts, the Six Big Losses,
ISA-18.2 alarm load, control charts, the conservative baseline and the RCA copilot. Each result is
canonically encoded and digested; the suite runs twice in this process and once in each of two
fresh interpreters started at different PYTHONHASHSEED values — the arm that catches a set or
dict iteration order reaching a result, which a single run never can. The socket API raises
throughout, so a computation that reached for a device or a hostname fails here instead of quietly
working on a machine that happened to be online. Afterwards the run is asked what it pulled in:
a model library that was already loaded (an MCP server holds iaiops.core.llm for the opt-in
narration tool) is recorded, not judged — only what the suite itself imported can condemn it.
The record separates result (identical every run — the part to sign) from context (when and
where this run happened). Two good runs are not byte-identical records, and someone will diff
them, so the halves are named rather than mixed.
This is the form the claim has to take to be usable: not "our model is accurate", which is not
evidence in a GxP context, but a test case someone can write into an IQ/OQ protocol — remove the
model, block the network, re-run the standard dataset, compare the hash — execute, and sign.
verify_determinism is the same check from the MCP side; iaiops verify suite lists what it
covers without running it.
How far it's actually been verified
Short version: verified against real protocol libraries, containers and in-process servers — not yet against real plant gear. We grade evidence rather than saying "tested", because a real container round-trip and a synthetic fixture are not the same claim.
| Rung | Who judges our frames | Protocols at this rung today |
|---|---|---|
| 2a — real wire to a third-party server | somebody else's implementation of the spec | OPC-UA (Microsoft opc-plc; certificate trust enforced both ways) · Modbus TCP (pymodbus) · Modbus RTU (pymodbus over a socat PTY) · MQTT / Sparkplug B (a real mosquitto broker) · BACnet/IP (bacpypes3, two IPs on one subnet) · MTConnect (the Institute's own cppagent) · SECS/GEM (secsgem equipment side) · EtherNet/IP (identification) |
| 2b — real wire to a server we wrote from the spec | the third-party client parses our frames | S7comm (pyS7) · Mitsubishi MC (pymcprotocol) · EtherNet/IP (tag layer — pycomm3, all three driver routes) · PROFINET-DCP |
| 2c — real wire, but both ends are ours | nobody independent | Omron FINS · IO-Link · HART-IP (codec itself at rung 1, against hart-protocol) · serial ASCII (our own weighing simulator) |
| mock only — protocol logic exercised, nothing on the wire | — | EtherCAT · BAS (Metasys / Niagara) · Ignition gateway |
| 3 — real physical / vendor device | the device | zero, for every protocol |
Each row's detail — and, per protocol, what is still not covered — is in
docs/VERIFICATION-RECORD.md. That file is the
record; this table is its summary, and a test fails if the two disagree.
Per-protocol evidence — including what each test does not cover — is in
docs/VERIFICATION-RECORD.md, one row per protocol, naming the test
behind each claim. Every 待核实 is hardware-gated, not forgotten — each one names the equipment that would settle it.
🧪 测试与共创 / Beta testing & co-creation
我们在找现场测试伙伴。 软件里能验证的我们都验证了(真实 in-process 服务器、真实协议库、Docker 容器 loopback)——剩下的 待核实 清单只有真设备能回答:物理 Modbus-RTU(RS-485)、EtherCAT 从站、HART 网关、在线 BACnet 楼宇设备、在线 Metasys/Niagara BAS 控制器、在线 Ignition 网关、国产 PLC(汇川/信捷)、真机 PLCnext、真实变电站 RTU/IED、欧姆龙 FINS 真机、IO-Link 主站。如果你是 OT 工程师、系统集成商或工厂团队,手上有任何这类设备:装上 iaiops,对你的设备跑一遍 iaiops doctor,把结果告诉我们。经你验证的设备会署名写进支持矩阵;现场反馈的问题我们优先分诊;功能可以通过 GitHub Issues/Discussions 直接共创。
We're looking for field-testing partners. Everything software-verifiable has been verified; what's left on the honest 待核实 list only real equipment can answer — physical Modbus-RTU (RS-485), EtherCAT slaves, HART gateways, live BACnet HVAC, live Metasys/Niagara BAS controllers, live Ignition gateway, domestic PLCs (Inovance/Xinje), live PLCnext, substation RTUs/IEDs, live Omron FINS PLCs, IO-Link masters. If you're an OT engineer, integrator, or factory team with access to any of these: install iaiops, run iaiops doctor against your gear, and tell us what happened. Verified-equipment reports get credited in the support matrix, field-reported issues get fast triage, and features are co-designed in the open via GitHub Issues/Discussions.
👉 参与入口 | Start here: open an issue with the protocol and device model in the title, or email [email protected]. Either reaches a person, and a report gets answered against the current release.
Consolidated capability matrix
| Protocol | Tool | Operation | R/W | risk_tier | Returns (key fields) |
|---|---|---|---|---|---|
| OPC-UA | opcua_server_info | server status | R | low | state, product_name, namespaces |
| OPC-UA | opcua_browse | browse node tree | R | low | [{node_id, browse_name, depth}] |
| OPC-UA | opcua_read_node | read one node | R | low | value, datatype, source_timestamp, good |
| OPC-UA | opcua_read_many | batch read | R | low | [{node_id, value, ...}] |
| OPC-UA | opcua_subscribe_sample | bounded sample | R | low | {collected, samples[]} |
| OPC-UA | opcua_read_alarms | alarm surfacing | R | low | {active_alarms[], active_count} |
| OPC-UA | opcua_read_history | Historical Access (HDA) | R | low | {supported, count, values[]} |
| OPC-UA | opcua_diagnose_connection | connection triage | R | low | {verdict, checks[]} |
| OPC-UA | opcua_discover_tags | tag auto-discovery → semantic asset model | R | low | {tag_count, assets[], naming_report} |
| OPC-UA | opcua_health_summary | threshold classify (was health_summary¹) | R | low | {overall, counts, offenders[]} |
| OPC-UA | opcua_anomaly_scan | stddev outliers (was anomaly_scan¹) | R | low | {mean, stddev, outliers[]} |
| Modbus | modbus_read_holding | FC03 | R | low | {raw_registers, decoded[]} |
| Modbus | modbus_read_input | FC04 | R | low | {raw_registers, decoded[]} |
| Modbus | modbus_read_coils | FC01 | R | low | {bits[]} |
| Modbus | modbus_read_discrete | FC02 | R | low | {bits[]} |
| Modbus | modbus_detect_byte_order | byte/word-order auto-detect | R | low | {best_order, candidates[]} |
| Modbus | modbus_list_templates | vendor register templates | R | low | {templates[]} |
| Modbus | modbus_apply_template | decode block via template | R | low | {values:{name: engineering_value}} |
| Modbus | modbus_health_summary | threshold classify | R | low | {overall, counts, offenders[]} |
| S7comm | s7_cpu_info | CPU id + run/stop | R | low | {cpu_status, cpu_info} |
| S7comm | s7_read_area | read DB/M/I/Q | R | low | {items:[{address, value}]} |
| S7comm | s7_read_db | read data block | R | low | {items:[{address, value}]} |
| S7comm | s7_read_many | batch addresses | R | low | {items:[{address, value}]} |
| S7comm | s7_write_db | write data block | W | high/MOC | {before, written, _undo_id} |
| Mitsubishi MC | mc_cpu_status | CPU type | R | low | {cpu_type, cpu_code} |
| Mitsubishi MC | mc_read_words | word devices | R | low | {words[]} |
| Mitsubishi MC | mc_read_bits | bit devices | R | low | {bits[]} |
| Mitsubishi MC | mc_read_many | random read | R | low | {words[], dwords[]} |
| Mitsubishi MC | mc_write_words | write words | W | high/MOC | {before, written, _undo_id} |
| Omron FINS | fins_cpu_info | controller data read (0501) | R | low | {controller_model, controller_version} |
| Omron FINS | fins_cpu_status | controller status (0601) | R | low | {run_mode, status} |
| Omron FINS | fins_read_words | memory-area word read (DM/CIO/W/H/A/EM) | R | low | {words[]} |
| Omron FINS | fins_read_bits | memory-area bit read | R | low | {bits[]} |
| Omron FINS | fins_read_many | batch reads | R | low | {items[]} |
| Omron FINS | fins_write_words | memory-area write | W | high/MOC | {before, written, _undo_id} |
| MTConnect | mtconnect_probe | device model | R | low | {devices:[{components:[{data_items}]}]} |
| MTConnect | mtconnect_current | latest values | R | low | {observations[]} |
| MTConnect | mtconnect_sample | bounded stream | R | low | {observations[]} |
| MTConnect | mtconnect_assets | assets | R | low | {assets[]} |
| MTConnect | mtconnect_oee_snapshot | OEE inputs | R | low | {availability, execution, verdict} |
| MQTT/Sparkplug | mqtt_read_topic | bounded read | R | low | {messages:[{topic, payload}]} |
| MQTT/Sparkplug | sparkplug_subscribe_sample | bounded SpB sample (full decode) | R | low | {samples:[{sparkplug, payload:{metrics[]}}], seq_gap_count} |
| MQTT/Sparkplug | sparkplug_decode_payload | decode raw SpB payload | R | low | {metrics:[{name, alias, datatype, value, is_historical}]} |
| MQTT/Sparkplug | sparkplug_node_list | node discovery + state | R | low | {nodes:[{group_id, edge_node_id, online, devices}], primary_hosts[]} |
| MQTT/Sparkplug | uns_browse | topic-tree browse | R | low | {topics[], tree{}} |
| MQTT/Sparkplug | uns_topic_audit | UNS naming + sprawl governance | R | low | {verdict, sprawl_findings, findings{casing_collisions[], scattered_leaves[], …}} |
| MQTT/Sparkplug | uns_schema_drift | Sparkplug schema-drift (baseline vs current) | R | low | {verdict (none/additive/breaking), node_changes[]} |
| MQTT/Sparkplug | uns_live_audit | live UNS audit (bounded broker sample) | R | low | {verdict, findings{}} |
| MQTT/Sparkplug | sparkplug_live_schema | live NBIRTH schema snapshot | R | low | {nodes[], metrics[]} |
| MQTT/Sparkplug | uns_live_drift | live drift vs stored baseline | R | low | {verdict, node_changes[]} |
| MQTT/Sparkplug | mqtt_publish | publish/command | W | high/MOC | {published_bytes, applied} |
| EtherNet/IP | eip_controller_info | Logix controller id | R | low | {controller:{vendor, product_name, revision, serial}} |
| EtherNet/IP | eip_list_tags | tag discovery | R | low | {tag_count, tags:[{name, data_type, structure}]} |
| EtherNet/IP | eip_read_tag | read one tag/array | R | low | {tag, value, type, good} |
| EtherNet/IP | eip_read_many | batch read | R | low | {items:[{tag, value, type}]} |
| EtherNet/IP | eip_write_tag | write tag | W | high/MOC | {before, written, _undo_id} |
| Diagnostics | diagnose_dataflow | localize no-data | R | low | {verdict, diagnosis, hops[]} |
| Diagnostics | alarm_bad_actors | ISA-18.2 flood | R | low | {flood_verdict, top_offenders[]} |
| Diagnostics | tag_health | offender ranking | R | low | {overall, offenders[]} |
| Diagnostics | historian_health | gap/flatline | R | low | {verdict, gaps[]} |
| Diagnostics | subscription_health | sequenced-feed loss/reorder/overload | R | low | {verdict, missed_count, overloaded_channels[]} |
| Diagnostics | downtime_root_cause | AI downtime RCA copilot (cited, advisory) | R | low | {verdict, primary_cause, hypotheses:[{cause, confidence, evidence[]}]} |
| Diagnostics | downtime_root_cause_live | RCA copilot that gathers its own live evidence | R | low | {…downtime_root_cause…, collected_evidence} |
| Diagnostics | learn_cause_weights | learn per-site RCA cause weights from labeled incidents | R | low | {cause_weights{}, rationale} |
| Diagnostics | data_quality_scorecard | fleet data-trust rollup | R | low | {fleet_score, fleet_status, issue_breakdown, worst_tags[], endpoints[]} |
| Diagnostics | data_quality_fleet_rollup | cross-endpoint fleet view | R | low | {fleet_score, endpoints[]} |
| Diagnostics | heartbeat_health | heartbeat/watchdog liveness | R | low | {alive, distinct_transitions, longest_stall_s, reason} |
| Alarm (ISA-18.2) | alarm_flood_analysis | flood episodes / chattering / stale / summary | R | low | {episodes[], chattering[], stale[], summary{}} |
| Alarm (ISA-18.2) | alarm_rationalization_worksheet | CSV-exportable rationalization rows | R | low | {rows[], csv_path?} |
| Baseline | baseline_learn | conservative change-log baseline (refuses thin history) | R | low | {band{p1,p99,median,mad} | insufficient_data} |
| Baseline | baseline_check | silent-by-default violation check | R | low | {status, violations[] (cited)} |
| Baseline | baseline_record_change | record operator change (restarts learning) | R | low | {recorded, change_point} |
| Baseline | baseline_status | no_baseline / learning / ok / violation | R | low | {status, window} |
| Historian | historian_query | read history back out of sqlite/TDengine/IoTDB | R | low | {rows[], truncated} |
| Historian | historian_coverage | per-tag row counts + first/last ts | R | low | {tags:[{tag, rows, first, last}]} |
| PLC program | plc_program_outline | structure of exported ST/AWL/L5X program | R | low | {blocks[], call_graph, timers[]} |
| PLC program | plc_program_xref | symbol/address cross-reference (cited lines) | R | low | {sites:[{kind, source_file, line, quote}]} |
| PLC program | plc_program_section | one named block's source (≤200 lines) | R | low | {text, source_file} |
| Export | export_data | export local store → CSV/SQLite/Parquet | R | low | {path, row_count, preview[]} |
| Analytics | oee_compute | OEE = A×P×Q | R | low | {availability, performance, quality, oee, oee_pct} |
| Analytics | downtime_events | stoppage detect + categorize | R | low | {event_count, total_downtime_s, by_category, events[]} |
| Analytics | oee_multidim | OEE machine×part×shift | R | low | {matrix[], worst_performers[], mean_oee} |
| Analytics | asset_inventory | active fingerprint | R | low | {assets:[{protocol, vendor, model, firmware, reachable}]} |
| Analytics | cross_protocol_asset_model | merge discovered tags into one asset model | R | low | {assets[], tag_count} |
| Analytics | adopt_alias_map / diff_alias_map | tag alias-map adopt/diff | R | low | {aliases{}, changes[]} |
| Analytics | monitor_changes | bounded change-of-value | R | low | {change_count, changes:[{value, previous, wall_clock}]} |
| EtherCAT | ethercat_master_state | master/WKC + slave count | R | low | {master_state, expected_working_counter, slaves_found, slaves_expected} |
| EtherCAT | ethercat_slaves | bus scan | R | low | {slave_count, slaves:[{index, name, vendor_id, product_code, state}]} |
| EtherCAT | ethercat_slave_info | slave detail | R | low | {sync_managers[], fmmus[], object_dictionary[], input_bytes} |
| EtherCAT | ethercat_read_sdo | CoE SDO upload | R | low | {index, byte_length, hex, as_uint} |
| EtherCAT | ethercat_read_pdo | input PDO snapshot | R | low | {working_counter, input_hex, input_byte_length} |
| EtherCAT | ethercat_write_sdo | CoE SDO download | W | high/MOC | {before, written, applied} |
| EtherCAT | ethercat_set_state | AL-state transition | W | high/MOC | {before, requested, reached, applied} |
| PROFINET | profinet_discover | DCP IdentifyAll (segment-wide) | R | low | {station_count, stations:[{name_of_station, mac, ip, vendor_id, device_roles[]}]} |
| PROFINET | profinet_identify_station | identify by name-of-station | R | low | {found, name_of_station, mac, ip, device_family} |
| PROFINET | profinet_station_params | targeted DCP Get (by MAC) | R | low | {found, name_of_station, ip, netmask, gateway} |
| PROFINET | profinet_asset_inventory | DCP asset register | R | low | {asset_count, io_controller_count, assets[]} |
| PROFINET | profinet_dcp_set | DCP Set (station name / IP suite) | W | high/MOC | {before, applied, _undo_id} |
| SECS/GEM | secsgem_equipment_status | GEM link + identity (S1F1/F2) | R | low | {communication_state, are_you_there} |
| SECS/GEM | secsgem_list_status_variables | SVID namelist (S1F11/F12) | R | low | {count, status_variables[]} |
| SECS/GEM | secsgem_read_status_variables | SVID values (S1F3/F4) | R | low | {svids, values[]} |
| SECS/GEM | secsgem_list_equipment_constants | ECID namelist (S2F29/F30) | R | low | {count, equipment_constants[]} |
| SECS/GEM | secsgem_read_equipment_constants | ECID values (S2F13/F14) | R | low | {ecids, values[]} |
| SECS/GEM | secsgem_list_alarms | alarm list (S5F5/F6) | R | low | {count, alarms[]} |
| SECS/GEM | secsgem_list_process_programs | PPID directory (S7F19/F20) | R | low | {count, process_programs[]} |
| BACnet (building) | bacnet_discover | Who-Is device discovery | R | low | {device_count, devices:[{device_id, address}]} |
| BACnet (building) | bacnet_object_list | a device's objects | R | low | {object_count, objects:[{object_type, instance}]} |
| BACnet (building) | bacnet_read_property | one object property | R | low | {object_type, instance, property, value} |
| BACnet (building) | bacnet_read_points | all present-values (HVAC snapshot) | R | low | {point_count, points:[{object_type, instance, present_value}]} |
| BACnet (building) | bacnet_cov_subscribe | bounded COV capture (always unsubscribes) | R | low | {notifications[], terminated_reason} |
| BACnet (building) | bacnet_read_trend_log | TrendLog readRange (bounded) | R | low | {records:[{timestamp, value}]} |
| BACnet (building) | bacnet_write_property | present-value write (priority) | W | high/MOC | {before, written, _undo_id} |
| HART-IP (process) | hart_device_identity | cmd 0 identity | R | low | {manufacturer, device_type, revision} |
| HART-IP (process) | hart_primary_variable | cmd 1 PV | R | low | {value, unit} |
| HART-IP (process) | hart_dynamic_variables | cmd 3 PV/SV/TV/QV + loop current | R | low | {variables[], loop_current} |
| HART-IP (process) | hart_burst_sample | bounded burst-variable sampling | R | low | {samples[]} |
| IO-Link | iolink_master_info | master identity | R | low | {vendor, product, serial} |
| IO-Link | iolink_ports | ≤32-port sweep (mode/status/device id) | R | low | {ports[]} |
| IO-Link | iolink_device_info | per-port device identity | R | low | {vendor_id, device_id, product_name} |
| IO-Link | iolink_read_pdin | process-data-in (raw hex + bytes) | R | low | {hex, bytes[]} |
| IO-Link | iolink_read_isdu | ISDU acyclic parameter read | R | low | {index, subindex, value} |
| IO-Link | iolink_scan | master + all connected devices | R | low | {master{}, devices[]} |
| Serial ASCII | serial_sniff | raw lines from a serial instrument (sends nothing) | R | low | {endpoint, seconds, lines[], note} |
Sensor (IAIOPS_MCP=sensor) | sensor_import_iodd | draft a profile from an IODD (待核实; returns text, writes nothing; reads only under the agent read roots — see Safety & governance) | R | low | {draft, profile_id, device_name, notes[]} |
Sensor (IAIOPS_MCP=sensor) | sensor_profile_check | validate a profile file, list its channels (reads only under the agent read roots) | R | low | {id, protocol, verified, channels:[{channel, layout, unit, signal, band}]} |
Sensor (IAIOPS_MCP=sensor) | sensor_read_channel | read one point once; reachable / decodable reported separately | R | low | {levels:{reachable, decodable}, value?, unit?, band?, verified?} |
Sensor (IAIOPS_MCP=sensor) | sensor_profile_list | list the shipped profile library (every entry 待核实 unless it carries provenance) | R | low | {profiles:[{id, protocol, verified, caveat}]} |
Sensor (IAIOPS_MCP=sensor) | sensor_profile_show | one library profile: summary + YAML text (writes nothing) | R | low | {summary, yaml, next} |
| BAS (Metasys/Niagara) | bas_point_list | supervisory point directory | R | low | {point_count, points:[{id, name, type}]} |
| BAS (Metasys/Niagara) | bas_point_read | read one supervisory point | R | low | {point, value, unit, status} |
| BAS (Metasys/Niagara) | bas_alarm_list | active controller alarms | R | low | {alarm_count, alarms:[{id, priority, state}]} |
| BAS (Metasys/Niagara) | bas_trend_read | trend/history samples (bounded) | R | low | {records:[{timestamp, value}]} |
| BAS (Metasys/Niagara) | bas_command | supervisory command (default-OFF; life-safety object denylist refuses fire/smoke/egress/pressurization before any I/O) | W | high/MOC | {before, written, _undo_id} |
| Ignition | ignition_gateway_status | Gateway + module health | R | low | {state, version, modules:[{name, state}]} |
| Ignition | ignition_tag_browse | tag-tree browse | R | low | {tags[], tree{}} |
| Ignition | ignition_tag_read | current tag values | R | low | {values:[{path, value, quality, timestamp}]} |
| Ignition | ignition_alarm_status | active alarms | R | low | {alarm_count, alarms:[{path, priority, state}]} |
| Ignition | ignition_tag_history | tag-history query (bounded) | R | low | {rows:[{path, timestamp, value}]} |
| 信创 / compliance | compliance_mapping | 《工控网络安全防护指南》↔ iaiops | R | low | {pillars[], status_summary, controls:[{pillar, status, gap}]} |
| 信创 / compliance | compliance_frameworks | 等保 2.0 + IEC 62443 FR1–6 + GxP crosswalk; framework="nis2"|"cra" = NIS2 Art. 21 / CRA Annex I item by item | R | low | {crosswalk[]} | {items:[{ref, coverage, mechanisms, gap}]} |
| 信创 / compliance | compliance_dengbao_levels | 等保 二级 baseline vs 三级 增量 | R | low | {pillars:[{l2, l3_delta, status}]} |
| 信创 / compliance | compliance_report | deliverable compliance report (md/html); framework="nis2"|"cra"|"eu" for the English EU self-assessment | R | low | {markdown | out_path} |
| 信创 / compliance | compliance_evidence_bundle | audit-evidence zip (hash-chain verified) | R | low | {bundle_path, manifest} |
| 信创 / historian | historian_push | push telemetry to sqlite/TDengine/IoTDB | R(→historian) | low | {sink, received, written, skipped_non_numeric} |
| Self | protocols_supported | capability map | R | low | {protocols[], diagnostics[], analytics[]} |
(The energy protocols — IEC-104 / DNP3 / IEC-61850 — moved to iaiops-energy in 0.8.0; their tool matrix lives in that repo.)
204 governed tools = 187 read + 4 that change nothing on a device but send data off-box (stream_publish, stream_publish_event, uns_publish, rca_narrate) + 10 MOC-gated device writes + historian_push (a write, to a historian rather than to a device: [WRITE][risk=low]) + the 2 deprecated aliases below. The device writes are (s7_write_db, mc_write_words, fins_write_words, mqtt_publish, eip_write_tag, ethercat_write_sdo, ethercat_set_state, profinet_dcp_set, bacnet_write_property, bas_command). The read side now includes two vendor-REST read-only layers above the field protocols — a BAS controller layer (Metasys/Niagara, building edition) and an Ignition Gateway MES/SCADA layer (factory edition). ¹ The 2 deprecated aliases are the two deprecated brain aliases health_summary / anomaly_scan, renamed to opcua_health_summary / opcua_anomaly_scan in 0.10.0 — the deprecated aliases are still registered and will be removed in a future release (target: 1.0.0). Read-only per-edition tools load ONLY under their edition (see per-edition tool modules below), so a bare protocol / single-edition surface is smaller than this line-wide total. The table above is representative, not exhaustive; run protocols_supported() (or iaiops protocols) for the live map.
Per-protocol reference
OPC-UA
- Versions/variants: binary
opc.tcp://viaasyncua(sync facade). Security: anonymous + username/password, plus application-certificate message security (Sign / SignAndEncrypt) — setclient_cert+client_key(+ optionalserver_cert) and the client opens a signed/encrypted secure channel (no cert ⇒ the anonymous / username path is unchanged). Validated end-to-end against an in-process asyncua server (tests/test_opcua_security.py) for Basic256Sha256 in both Sign and SignAndEncrypt modes:server_certpinning and client-side server-cert auto-discovery are exercised, and the test asserts the negotiated policy URI + message-security mode on the live encrypted channel (plus a negative test that anonymous is refused by a secure-only server). - Connection params:
endpoint_url,username(password encrypted),security_mode,security_policy; for cert securityclient_cert/client_key/ optionalserver_cert(PEM or DER paths; aliasescertfile/keyfile). - Alarms & Conditions:
opcua_alarm_events— bounded event subscription +ConditionRefresh, events carry the server's own timestamps (verified against an in-process asyncua server; third-party A&C servers待核实). Untimed fallback:opcua_read_alarmsbrowses alarm-like boolean nodes. - Not supported / planned (
待核实): cert-security interop with third-party / vendor servers (KEPServerEX / Prosys / Siemens / real PLCs), the other policies (Aes128Sha256RsaOaep / Aes256Sha256RsaPss / Basic128Rsa15 / Basic256), strict server-side certificate-trust enforcement, and cert-based user identity (X509 identity token, distinct from channel security).
Modbus-TCP / Modbus-RTU
- Versions/variants: Modbus-TCP and Modbus-RTU (serial RS-485/232) via
pymodbus(+pyserial). Read function codes FC01 (coils), FC02 (discrete), FC03 (holding), FC04 (input). Write FCs (FC05/06/15/16) = not implemented (read-only). - Connection params: TCP —
host,port(502),unit_id. RTU —transport: rtu,serial_port(e.g./dev/ttyUSB0),baudrate,unit_id. Registers are untyped 16-bit words →decodehint (uint16/int16/uint32/int32/float32/raw);modbus_detect_byte_orderauto-detects the byte/word order (AB/BA · ABCD/DCBA/BADC/CDAB) from hint values — pure logic, no extra device load. - Typed points for collection (HLD §16): a collected/monitored ref can name its table, type, word order and scale —
ir:0:float32:CDAB,hr:12:int16*0.1(grammar<hr|ir>:<addr>[:<type>[:<order>]][*<scale>]; a 32-bit type must state its order). Withsensor_profile: <file>on the endpoint,ch:<name>reads a channel of that profile (sentinels → gaps, declared range enforced). A bare address keeps its old meaning (one holding register, uint16) andreadinessreports it as untyped. Switching sensors (NPN / PNP / dry contact) on a DI module read asdi:<addr>:<high|low>(FC02) orco:<addr>:<high|low>(FC01): the active level is required (a normally-closed switch reads 0 when actuated), the value is 1 = actuated, andreadinessreports acount_sourcegap when a switching point is declared as the production counter (polling misses short pulses) — checked on Modbus refs only; PLC bits are not yet checked. A shipped profile library (iaiops sensor list | show, MCPsensor_profile_list | show) holds community register maps for common energy meters and PV inverters, each derived from the Modbus template of the same device and carrying its caveat — every entry待核实; CI checks each entry against its template channel by channel, in both directions. Copy one (show <id> --out <file>), check a reading against the device's display, then bind the copy. - Vendor register templates (
modbus_list_templates/modbus_apply_template): named register maps decoding a block into engineering values — energy meters (Eastron SDM630, Schneider PM5xxx, Carlo Gavazzi EM24), PV inverters (Huawei SUN2000, Growatt), Phoenix PLCnext process data, and water-industry templates (E+H Promag, Hach SC controller, generic dosing pump). Each template carries an explicit待核实caveat — no invented "verified" addresses. - Coverage: many domestic 国产 PLCs (汇川 Inovance / 信捷 Xinje / 和利时 Hollysys / 台达 Delta) and any Modbus vendor. RTU framing is live-verified over a real serial link (socat PTY + pymodbus RTU server); specific physical RS-485 devices stay
待核实.
S7comm (Siemens + 仿西门子 国产)
- Versions/variants:
pyS7(pure-Python, ISO-on-TCP / RFC1006 — no nativelibsnap7). S7-300/400/1200/1500 and compatible clones. Memory areas DB / M (merker) / I / Q. No protocol auth (CPU gates via "Permit access with PUT/GET"). - Connection params:
host,port(102),rack,slot(0/1 for 1200/1500; 0/2 common for 300/400). - Write:
s7_write_db= high risk_tier, MOC, dry-run default, captures BEFORE value + undo. - Not supported / planned: optimized/symbolic DB access on 1500 with "optimized block access" can require absolute-addressing config on the CPU.
Mitsubishi MC
- Versions/variants:
pymcprotocol— MC 3E frame (binary) only. 1E / 4E frames = not supported. PLC types Q / L / QnA / iQ-R / iQ-L. Devices: D/W/R (word), M/X/Y/B (bit). - Connection params:
host,port(5007 default; set to the module's open MC port),plctype. - Write:
mc_write_words= high/MOC/dry-run default, captures BEFORE + undo.
Omron FINS (CS/CJ/CP/NX-via-FINS)
- Versions/variants: in-repo, stdlib-only FINS client (no third-party dependency — the
iaiops[fins]extra pins nothing): 10-byte FINS header framing, FINS/UDP (default port 9600) and FINS/TCP (node-address handshake per Omron W342), SID matching, bounded response parsing, end-code table per W227/W342. Commands: 0101 memory-area read (words/bits over DM/CIO/W/H/A/EM), 0102 write, 0501 controller data read, 0601 controller status. - Connection params:
host,port(9600),transport(udpdefault /tcp), FINS network/node/unit addressing. - Write:
fins_write_words= high/MOC/dry-run default, captures BEFORE + undo; CLI double-confirm on--apply. - Validation: verified against an in-repo mock FINS UDP/TCP responder (
tests/test_fins.py); live Omron PLC behaviour and banked-EM access stay待核实.
IO-Link (master JSON integration — read-only)
- Versions/variants: sensor-level visibility via the IO-Link master's HTTP/JSON interface (IO-Link consortium "JSON Integration"), both dialects selectable per endpoint via
flavor:—iotcore(ifm IoT-Core POST envelope, default) andrest(plain-REST GET, Balluff/Turck-style). Reads: master identity, bounded ≤32-port sweep, per-port device identity, process-data-in (raw hex + bytes), ISDU acyclic parameter read. NO write tools. Bounded/size-capped HTTP (256 KiB response cap), schema-checked JSON with teaching errors. Reuses the MTConnect HTTP pin (iaiops[iolink]→requests). - Connection params: master
host/URL,flavor,timeout_s.protocol: iolink. - Collection (HLD §16):
port_profiles: {<port>: <profile.yaml>}binds a sensor profile per port;port<N>/<channel>refs are then collectable (collect,monitor). A port without a profile is not decoded — refused, never guessed. If the profile carriesmatch: {vendor_id, device_id}, the device on the port is checked before decoding (cached 5 s per master and port, dropped on any failed read). Withpd_bits, a process-data image of another length is refused.iaiops sensor import-iodd <iodd.xml>drafts a profile from the vendor's IODD (verified: 待核实, unit codes left unmapped, unrepresentable items listed);iaiops sensor checkvalidates one. - Validation: in-process mock master in both flavors (
tests/test_iolink.py); profile decoding against a hand-written IODD-shaped fixture (tests/fixtures/iodd/). Live master datapoint paths, identity encodings and real vendor IODDs stay待核实.
Serial ASCII instruments (scales, analysers, barcode readers, legacy meters — read-only)
- Versions/variants: instruments that send one text line per reading over RS-232 / RS-485 / RS-422 / USB virtual COM (
pyserial>=3.5, extraiaiops[serial], included iniaiops[sensor]) or through a serial device server's raw TCP socket. Decoding is a sensor profile (protocol: serial):framing(terminator, encoding, max line),mode: listen | poll(+ the ONEquerypoll may send), and per channel a regex that must match the whole line with avaluegroup, status codes (ok/ named invalid → gaps) and the unit fixed and/or from the line. - Safety rules: the port is opened exclusively — a port another program holds is refused, never taken (an advisory lock: a program that opened it without locking cannot be detected, 待核实 per OS). Poll needs
bus: dedicatedon the endpoint (shared= an RS-485 bus that already has a master: listen only); tare / zero / calibrate are not reads and do not belong inquery. Listen needsstale_after_s(no line within it = a gap, never the last value). Collection requires a fixed unit per channel. - Tools:
serial_sniff/iaiops serial sniff(raw lines, sends nothing); decoded reads viasensor_read_channel/iaiops sensor read; collection viaiaiops collect run. - Validation: a pty pair through the real pyserial path, a local TCP device-server path, and a lab LAN walkthrough against our own weighing simulator (rung 2c — no third-party instrument). No serial profile ships — you write one per instrument (
iaiops serial sniffshows the raw lines), and its line format stays待核实until checked against the real device.
HART-IP (process instrumentation — read-only)
- Versions/variants: HART universal commands over HART-IP UDP (default, port 5094) or TCP (
transport: tcp, length-delimited framing) via an in-tree transport; the HART command codec is verified vshart-protocol. Tools:hart_device_identity(cmd 0),hart_primary_variable(cmd 1),hart_dynamic_variables(cmd 3, PV/SV/TV/QV + loop current),hart_burst_sample(bounded sampling of burst-published variables). No write / device-specific commands exposed (OT-dangerous on live instruments). - Connection params:
host(HART-IP server/gateway),port(5094),transport(udp default / tcp). - Validation: TCP transport loopback-verified (in-process HART-IP server, real long-frame ACK through the real codec path); live gateway behaviour and a true unsolicited burst subscription stay
待核实.
MTConnect (ALL CNC machine tools)
- Versions/variants: agent REST + XML (
requests+xml.etree), namespace-agnostic (parses MTConnect 1.x Devices/Streams/Assets schemas). Endpoints:/probe,/current,/sample,/assets. Read-only by specification. XML parsing is hardened (DTD/entity declarations rejected — XXE/billion-laughs defense). - Connection params:
agent_url(e.g.http://host:5000). - Not supported / planned: MTConnect streaming (long-poll
interval=); only boundedcount=samples.
MQTT / Sparkplug B / UNS
- Versions/variants:
paho-mqtt— MQTT 3.1.1 & 5. Sparkplug B topic conventionspBv1.0/{group}/{type}/{edge}/[device](NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/STATE). TLS + username/password supported. - Full Sparkplug B decode (no optional extra): payloads are protobuf-decoded with a vendored, byte-for-byte copy of the official Eclipse Tahu
sparkplug_b.protogenerated module (depends only onprotobuf). Per metric you get name, alias (resolved to its name via the BIRTH model), datatype (Int8…Int64/UInt…/Float/Double/Boolean/String/DateTime/Text/UUID/DataSet/Bytes/File/Template/PropertySet…), value, timestamp, and theis_historical/is_nullflags. A birth/death + seq model tracks node/device online state (NBIRTH/DBIRTH ↔ NDEATH/DDEATH), builds the alias→name map from BIRTH, applies NDATA/DDATA by alias, and flagsseqgaps / out-of-order. Primary-host awareness:STATE/<host_id>topics surface insparkplug_node_list.sparkplug_decode_payloaddecodes a single raw payload (base64/hex) offline. - A data SOURCE, not just a bus (new): MQTT/Sparkplug is collectable —
iaiops collect runholds ONE subscription open for the run and samples from its last-value cache, so a plant whose data is already unified into a UNS can be tapped without touching a PLC. Refusals are the feature. MQTT is pushed, and the obvious cache answers with the last value forever after a publisher dies — availability then reads 100% on a stopped line, in every subscriber at once. So a point is a reading only when it has been seen at all (never-published is not zero), is fresher thanstale_after_s, and — for Sparkplug — its node has not sent NDEATH/DDEATH. Anything else raisesOTNoReadingError, which the collector records as a gap (collection was blind), never as downtime. stale_after_sis REQUIRED to collect, and deliberately has no default: a value that stopped updating and one that is simply constant are identical on the wire, so only the site knows how often a point is published. Guessing it here would put a guess underneath every availability figure. Same discipline asrunning_whenon arun_statetag.- The two guarantees are not equal, and the endpoint is told which it has: Sparkplug gives staleness and death (the broker publishes the node's Last Will); plain MQTT has no death signal at all, so only staleness is available there and a quiet topic is indistinguishable from a dead publisher.
- Refs: a plain-MQTT ref is the topic itself; a Sparkplug ref is
group/edge[/device]:metric— exactly the node idmqtt live-schemareports, so its output is usable as config refs without translation. Payloads decode as a bare scalar or a{"value": …}/{"v": …}JSON envelope; an unknown envelope is not guessed at by picking its only number. - Connection params:
host/broker,port(1883 / 8883 TLS),topic,use_tls,username(password encrypted),stale_after_s(required for collection). - Validation: verified end-to-end against a real eclipse-mosquitto:2 broker through the full paho loop (
tests/test_uns_tap_live.py, opt-in) — publish → read → publisher stops → the run records a gap instead of repeating the cache. Also exercised across a real network on three separate hosts (broker, edge node, reader) against a spec-correct Sparkplug node — alias-only NDATA, periodic re-BIRTH, and a genuine broker-issued Last Will after the publisher waskill -9'd on another machine (refusal in ~2s withstale_after_sdeliberately set to 60, so only the death signal could have caused it). 待核实: the edge node is still our own implementation, not a commercial EoN (Ignition / Litmus / HighByte), and not a commercial broker (HiveMQ / EMQX). - Command:
mqtt_publish= high/MOC/dry-run default. A transient publish has no automatic inverse (delivered is delivered); a retained one overwrites durable broker state, so it captures the prior retained payload and records an inverse.
EtherNet/IP (Rockwell / Allen-Bradley)
- Supported: ControlLogix / CompactLogix (and GuardLogix) via CIP / EtherNet-IP using
pycomm3(pure-Python — no native deps). Tag-based, symbolic access: read/write tags by name (Conveyor.Speed,Array[3],Program:Main.X) and discover the controller's tag list at runtime (eip_list_tags, the headline feature).eip_controller_inforeads the controller identity. - Connection params:
host,slot(0 for CompactLogix; the CPU slot for a ControlLogix chassis),port(44818).protocol: ethernetip(aliaseip). - Write:
eip_write_tag= high risk_tier, MOC, dry-run default, captures BEFORE value + undo. - Also supported (mocked drivers only — real hardware
待核实): PLC-5 / SLC-500 / MicroLogix via PCCC data-table addressing, and Micro800.
EtherCAT (pysoem / SOEM fieldbus master)
-
Supported: a real EtherCAT master via
pysoem(the Python binding for the SOEM C stack). CoE SDO read (ethercat_read_sdo, acyclic mailbox upload) + SDO write (ethercat_write_sdo, download), input PDO read (ethercat_read_pdo, one bounded cyclic snapshot), bus scan / slave enumeration (ethercat_slaves,ethercat_slave_info— identity, SM/FMMU mapping, object-dictionary summary), master/working-counter state (ethercat_master_state), and AL-state transitions INIT↔PREOP↔SAFEOP↔OP (ethercat_set_state). -
HARD REQUIREMENTS (no way around them): Linux, root or
CAP_NET_RAW, a dedicated NIC cabled to the bus, and real EtherCAT slave hardware.pysoemis an OPTIONAL extra:pip install iaiops[ethercat]— the base package installs and imports without it, and every EtherCAT tool then degrades to a teaching error (never crashes, never imports pysoem at module load). -
NOT supported: no software simulator exists (unlike OPC-UA / Modbus) — EtherCAT is hardware-only and not testable in mock-only CI; macOS is unsupported. EoE / FoE / SoE mailbox protocols and full PDO-mapping decode/expansion = roadmap.
-
Connection params:
nic(the dedicated interface name, e.g.eth1; aliasinterface), optionalexpected_slaves(a sanity check vs the bus scan).protocol: ethercat. -
Operations matrix:
Tool Op R/W risk Capture/notes ethercat_master_statemaster + WKC state, slave count R low expected vs found ethercat_slavesbus scan / enumerate R low index/vendor/product/rev/addr/AL-state ethercat_slave_infoone-slave detail R low SM/FMMU + OD summary ethercat_read_sdoCoE SDO upload R low hex + uint interpretation ethercat_read_pdoinput PDO snapshot R low single cycle, never loops ethercat_write_sdoCoE SDO download W high/MOC before-value (SDO read-back) + undo ethercat_set_stateAL-state transition W high/MOC before-state + undo; can start/stop motion -
Write/state safety:
ethercat_write_sdo(hex little-endian bytes) andethercat_set_stateare high risk_tier, MOC, dry-run by default, capture the BEFORE value/state for undo, and need a CLI double-confirm. Changing EtherCAT state can START or STOP machine motion — treat with extreme care. 未经授权勿对生产控制系统写入.
PROFINET (DCP discovery / identify + gated DCP Set)
- Supported: layer-2 PROFINET-DCP via
pnio-dcp—profinet_discover(DCP IdentifyAll: one broadcast surfaces every station on the segment — name-of-station, MAC, IP, vendor/device id, role — closer to passive discovery than a per-device fingerprint),profinet_identify_station(by name-of-station),profinet_station_params(targeted DCP Get by MAC → name + IP suite), andprofinet_asset_inventory(a register with IO-controller vs IO-device role decoding). - Write:
profinet_dcp_setre-addresses one station (name-of-station and/or IP suite, by MAC) — high risk_tier, MOC, dry-run default, captures the BEFORE addressing + undo descriptor. Re-addressing a live station can disrupt its IO connection. - Scope (deliberate): no RT cyclic process data (that needs an IO-controller/IO-device stack and hard real-time — out of scope and unsafe to tap); the blink / factory-reset DCP services stay unexposed.
- HARD REQUIREMENTS: raw-socket access (root / admin /
CAP_NET_RAW) on the NIC on the PROFINET subnet.pnio-dcpis an OPTIONAL extra:pip install iaiops[profinet]— the base package installs/imports without it, and every tool then degrades to a teaching error. - Connection params:
host— THIS machine's IP on the PROFINET subnet (the DCP broadcast goes out on it).protocol: profinet. - Preview caveat: validated against a mocked
pnio-dcpDCP — not verified against live PROFINET devices yet.
Energy edition (electrical substation / utility telecontrol) → iaiops-energy
The energy vertical — IEC 60870-5-104 / DNP3 / IEC 61850 MMS read-only monitoring for substation RTUs/IEDs — moved to its own package in 0.8.0: iaiops-energy (pip install iaiops-energy), built on iaiops.core (shared governance / brain / runtime). Its protocol reference, support matrix, and validation status live in that repo.
Building edition (facility / HVAC / 厂务)
The building vertical adds BACnet/IP (ASHRAE 135) — the dominant building-automation protocol for HVAC, lighting, metering, and facility plant. Install with pip install iaiops[building] and expose with IAIOPS_MCP=building (bundle: bacnet + modbus + opcua + iolink).
- BACnet/IP (
BAC0over bacpypes3):bacnet_discover(Who-Is device discovery),bacnet_object_list(a device's objects),bacnet_read_property(one object property),bacnet_read_points(present-value of all analog/binary/multistate points — the HVAC snapshot),bacnet_cov_subscribe(bounded change-of-value capture — capped by count AND wall-clock, always unsubscribes),bacnet_read_trend_log(TrendLog buffered records via one bounded readRange). Config:host= THIS machine's BACnet/IP interface (iporip/mask) /port(47808). - Write:
bacnet_write_property(present-value at a BACnet priority 1..16, or relinquish) = high risk_tier, MOC, dry-run default, BEFORE-value read-back + undo. Overriding a live building-control point can move real HVAC/plant. - Validation: the read path is verified against a real bacpypes3 virtual BACnet/IP device through the actual async BAC0 stack (
tests/test_bacnet_live.py); COV / trend-log / writes on live HVAC gear stay待核实.
Water treatment edition (水处理)
IAIOPS_MCP=water (or iaiops-mcp-water, pip install iaiops[water]) exposes modbus + opcua + hart + the brain — the protocol set waterworks / wastewater plants actually run. Adds water-domain tag semantics (溶解氧 DO / ORP / 余氯 chlorine / 氨氮 ammonia / TSS/MLSS / 跨膜压差 TMP / UV / 加药 dosing / 曝气 aeration) and water-industry Modbus templates (E+H Promag, Hach SC controller, generic dosing pump — all with explicit 待核实 caveats).
Warehouse / intralogistics edition (仓储 / 物料搬运)
IAIOPS_MCP=warehouse (or iaiops-mcp-warehouse, pip install iaiops[warehouse]) exposes eip + profinet + modbus + opcua + sparkplug + the brain — conveyor & sorter drives over EtherNet/IP (Rockwell) and Profinet (Siemens), VFD / energy meters over Modbus (conveyor_vfd / agv_battery templates), WMS/WCS gateways over OPC-UA, and AMR/IoT telemetry over MQTT-Sparkplug. Edition tools (read-only, advisory): line_bottleneck (Theory-of-Constraints throughput bottleneck across stations) + sortation_health. PdM (pdm_forecast), downtime_triage and OEE are reused as-is.
Clinical-facility edition (医疗设施)
IAIOPS_MCP=clinical (or iaiops-mcp-clinical, pip install iaiops[clinical]) exposes bacnet + modbus + opcua + the brain — hospital facilities as a distinct patient-safety vertical over the building brain. Edition tools (read-only, advisory): isolation_room_check (负压/正压 isolation-room pressurization), medical_gas_check (medical-gas alarm-panel safety), or_environment_check (OR temperature / humidity / pressure envelope). BACnet BMS + Modbus gas-alarm panels + OPC-UA plant SCADA.
Pharmaceutical-manufacturing edition (制药)
IAIOPS_MCP=pharma (or iaiops-mcp-pharma, pip install iaiops[pharma]) exposes bacnet + modbus + hart + opcua + the brain. No new protocol — that is the point: no field protocol is specific to pharma. Cleanrooms run BACnet, purified-water systems run Modbus and HART, filling and lyophilization run S7, DCS and bioreactors run OPC-UA, and all of it was already here. What pharma needed was semantics: the water edition's indicators are municipal (DO, ORP, chlorine, turbidity) and the clinical edition grades one room's pressure, where Annex 1 inspects the cascade.
Edition tools (read-only, advisory): cleanroom_pressure_cascade (EU GMP Annex 1, door by door — adjacency is declared, never inferred from a room list), cleanroom_particle_check, pharma_water_check (USP <645> stage-1 procedure: the non-temperature-compensated reading, the measured temperature rounded down to the tabulated step, and exceeding stage 1 reported as proceed to Stage 2 rather than as a failure).
No compendial limit tables are shipped. The particle limits, the stage-1 conductivity table and the TOC limit belong to the site's qualified specification at its compendial revision. A transcription nobody in this repository can verify would end up deciding whether a batch environment passed — and the error that hurts is the flattering one, since a limit set too loose reads as "in specification". Limits are passed in and cited back; anything not declared is reported no_limit / not_graded and named, never counted as passing. Known gaps are listed in the edition's skill: no PI historian connector, S7 without hardware verification, and LIMS / QMS deliberately out of scope — they run REST and databases, not field protocols.
Renewables edition (光伏 / 风电)
IAIOPS_MCP=renewables (or iaiops-mcp-renewables, pip install iaiops[renewables]) exposes modbus + opcua + sparkplug + the brain — PV inverters (SUN2000 / Growatt templates) + wind-turbine controllers over Modbus, OPC-UA plant SCADA, and MQTT-Sparkplug telemetry. Edition tool (read-only, advisory): pv_performance (PV string performance vs expectation). Device-level monitoring + PdM via baseline / RCA.
Half-hourly electricity metering (supplier HH files — building & renewables editions)
A supplier's half-hourly (HH) electricity file, read and explained — the data every UK non-domestic meter produces as market-wide half-hourly settlement completes. No device is contacted; the input is a file. iaiops meter check FILE says what the file contains (wide layout: date + 46–50 half-hour columns; long layout: timestamp + kWh) and what was rejected; iaiops meter profile FILE [--hours 'mon-fri 08:00-18:00'] [--holiday YYYY-MM-DD] [--tariff tariff.yaml] [--report out.html] reports overnight base load, out-of-hours use against the declared operating hours, peak demand, complete-day working vs closed averages, load factor, a weekday × time heatmap and — with a tariff — cost by time band and an upper bound for moving use out of the dearest band. MCP (building + renewables editions): meter_hh_check, meter_load_profile (same engine; the HTML report is CLI-only). Demo: demo/metering/.
- What it refuses to do: a blank cell is missing, never zero; totals are measured only, never scaled up over gaps; dates are UK day-first only (
01/13/2026is rejected, not reread); a file with no actual/estimated flag reports read type unknown, not "actual"; out-of-hours use above base load is labelled an indication, not a saving. - Clock days: the UK clock (GMT/BST) is computed from its rule — no
tzdataneeded — so the 46- and 50-period clock-change days are read correctly. - Not built in: DNO DUoS red/amber/green times (they vary by region — pass them in a tariff;
demo/metering/tariff_example.yamlis illustrative only), VAT, CCL, kVA capacity and reactive charges (listed as excluded on every cost). Validation: synthetic files and the unit suite only — no real supplier file has been run through it yet (待核实).
PLCnext packaging edition (Phoenix Contact vPLC)
IAIOPS_MCP=plcnext (or iaiops-mcp-plcnext, pip install iaiops[plcnext]) exposes opcua + modbus + the brain — the Phoenix Contact PLCnext virtualized PLC reached over its built-in OPC-UA server (opc.tcp 4840, Arp.Plc.Eclr address space) + Modbus-TCP process-data server; no new connector. Route-verified in-process; live PLCnext hardware reads stay 待核实 (see How far it's actually been verified).
信创 / China entry (offline · 国产 TSDB · compliance)
For 自主可控 / 信创 deployments — see docs/CHINA.md for the full guide.
- Air-gapped install: pure-Python core + per-protocol optional extras → install from a local wheelhouse with
pip install --no-index --find-links ./wheelhouse "iaiops[...]"; secrets stay local (encrypted store), no cloud KMS. - National TSDB historian sink (
historian_push, CLIiaiops historian push): write collected telemetry to TDengine (iaiops[tdengine]) or Apache IoTDB (iaiops[iotdb]) — domestic, controllable; we don't build our own store or bind InfluxDB. Data egress to the operator's own historian, not a control write. - Compliance mapping (
compliance_mapping, CLIiaiops compliance): an honest 《工控系统网络安全防护指南》 ↔ iaiops self-assessment across 分区隔离 / 可审计 / 双向认证 / 最小权限 / 数据保护 / 自主可控, with per-control status (addressed / partial / 待核实) and the named gap. - 国产 PLC: 汇川 / 台达 / 信捷 over the existing Modbus-TCP / S7 connectors.
- ⚠️ 待核实: 国产 OS (麒麟/统信) · 芯 (鲲鹏/海光) · PLC validation and the TSDB write paths are documented but not yet hardware-verified — see the validation matrix in docs/CHINA.md.
OEE / downtime analytics (cross-protocol, read-only)
oee_compute— OEE = Availability × Performance × Quality from production inputs (planned time, run time, ideal cycle, total/good counts). Each factor is reported raw + clamped to [0,1]; acappedperformance >1.0 flags an optimistic ideal cycle.downtime_events— auto-detects running→stopped transitions in a{timestamp, state}series and produces stoppage events with durations, categorized (changeover / material / mechanical / quality / break / unknown, by keyword heuristics or a{state: category}override).oee_multidim— aggregates OEE across machine × part × shift (or any dimensions) from labelled records → the matrix + worst performers.- Operate over provided/collected inputs (fully testable without a plant).
mtconnect_oee_snapshotsurfaces the live MTConnect availability/execution inputs that feed these.
Active asset inventory / fingerprint (read-only)
asset_inventory— for each configured (or named) endpoint, actively connects with our own protocol client and reads its identity call (S7s7_cpu_info, EtherNet/IPeip_controller_info, OPC-UA server build info, Modbus Device Identification FC43/0x2B, Mitsubishi CPU type, MTConnect device model), aggregating vendor / model / firmware / serial / reachable / last_seen into an asset register.- Honest scope (IEC 62443-flavored): this is ACTIVE fingerprinting via our client connections, NOT passive SPAN/tap discovery — it only finds devices we are configured to reach and adds light load to each. Passive, traffic-mirroring discovery is roadmap.
OPC-UA Historical Access (HDA)
opcua_read_history— reads stored historical values for a node over a[start,end]ISO-8601 window via the server's HistoryRead service (asyncuaread_raw_history), bounded bymax_points(≤2000). Returns{supported:false, note}gracefully when the server does not historize the node (no crash). Read-only.
Change-of-value (CoV) monitor
monitor_changes— bounded deadband report: polls a point and returns only the value CHANGES (with timestamps), not every sample. Works over OPC-UA / Modbus / S7 / Mitsubishi MC / EtherNet-IP. Never an infinite loop — hard-capped by bothduration_s(≤120) andmax_changes(≤500). Read-only.
Cross-protocol brain — 0.9/0.10 additions (all read-only)
- Conservative baseline learning (
baseline_learn/check/record_change/status, CLIiaiops baseline …) — a change-log baseline, explicitly NOT black-box anomaly detection: robust p1/p99 + median/MAD band over the local history, refuses thin history (<100 samples or <24h) with an explicitinsufficient_dataverdict, restarts at recorded operator changes, and is silent by default — a violation needs >3×MAD beyond the band AND ≥3 consecutive samples, and every violation cites its baseline window and offending samples. - Historian READ integration (
historian_query/historian_coverage, CLIiaiops historian query|coverage) — query history back out of the sqlite/TDengine/IoTDB sinks; an optional per-sitehistorian:config block lets the RCA copilot pull the 2h pre-incident window as one more cited evidence class (strictly additive — without the config, RCA output is byte-identical, test-proven). - Legacy PLC program explainer (
plc_program_outline/xref/section, CLIiaiops program …) — structural extraction over exported program files (Siemens SCL/ST.scl/.st, AWL/STL.awl, Rockwell Studio 5000.L5X— never a live PLC upload); every element carriessource_file+ line (rung for L5X) so the explaining agent must cite real locations. XXE-hardened, ≤5 MB, extension allowlist. - ISA-18.2 alarm flood deep-dive (
alarm_flood_analysis/alarm_rationalization_worksheet, CLIiaiops diag alarm-flood|alarm-worksheet) — flood episodes (≥10 alarms/10 min), chattering, stale/standing (>24h), percent-time-in-flood vs target, and a CSV-exportable rationalization worksheet; over injected events or a live OPC-UA active-condition scan. - Open-format export + metrics bridge —
iaiops export csv|sqlite|parquet(from the local SQLite sink; Parquet viaiaiops[export]) / MCPexport_data;iaiops metrics serve --port 9184exposes Prometheus/metrics(latest tag values + counters, binds 127.0.0.1 by default) — Grafana recipe indocs/GRAFANA.md. - Compliance deliverables —
iaiops compliance report(等保 2.0 L2/L3 status + IEC 62443 FR1–6 crosswalk + honest gap list, md/html) andiaiops compliance evidence(audit-evidence zip with hash-chain verification + manifest); MCPcompliance_report/compliance_evidence_bundle. Onboarding aids, 非认证. - NIS2 / CRA self-assessment —
iaiops compliance --framework nis2|craandiaiops compliance report --framework nis2|cra|eu(MCP: theframeworkargument oncompliance_frameworks/compliance_report; no new tool). NIS2 Art. 21(2)(a)–(j) and CRA Annex I Part I(1), I(2)(a)–(m), II(1)–(8), item by item:product_evidence(code-anchored mechanisms speak to the measure's central requirement — never "the measure is met", and every such row names what is left),partial_evidence(they touch it, but the central requirement is missing),gap, ororganisational(product provides no evidence). NIS2 obliges entities, the CRA manufacturers; this is a product self-assessment, not a certification and not legal advice. What it still admits: no MFA or per-user identity (one shared bearer token on the HTTP transport, no TLS of its own), an audit opt-out for successful reads only (writes, denials and failures are always recorded), audit database and collected history unencrypted at rest (iaiops resetremoves them but does not shred), no penetration test or fuzzing, and the SBOM, PyPI attestations and advisory process are in place but not yet exercised by a release or a report. Vulnerability handling is in SECURITY.md.
Cross-protocol brain & editions — 0.11/0.12 additions (all read-only)
- Downtime triage copilot (
downtime_triage) — composes alarm cascade + RCA verdict + PdM precursors into one triage and cross-checks whether the first-out alarm agrees with the diagnosed cause; advisory, cite-first (builds on the earlieralarm_cascadefirst-out reconstruction andpdm_forecasttime-to-threshold early-warning). - Legacy-PLC maintainability (
plc_program_visibility) — a risk/maintainability read over an exported ST/AWL/L5X program (size, block count, xref density, undocumented sections), never a live upload — pairs with theplc_program_outline/xref/sectionexplainer. - Per-edition tool modules (
EDITION_MODULESinmcp_server/profiles.py) — a named edition can carry its own@mcp.toolgroup that loads only when that edition is selected — never for a bare protocol key and never in the always-on brain, so edition-specific tools stay off other surfaces and don't inflate the base. Every edition tool is read-only, cite-first, advisory:- warehouse —
line_bottleneck(Theory-of-Constraints throughput bottleneck) +sortation_health - clinical —
isolation_room_check(负压隔离病房 pressurization) +medical_gas_check+or_environment_check - building —
economizer_check(AHU economizer FDD) +zone_comfort - process —
control_loop_health(PID oscillation/offset/saturation) +heat_exchanger_fouling - fab —
spc_check(SPC control-chart rules) +defect_pareto - factory —
changeover_analysis(SMED) - water —
disinfection_ct+water_quality_compliance - renewables —
pv_performance(PV string performance) - sensor (
IAIOPS_MCP=sensor/iaiops-mcp-sensor) —sensor_import_iodd+sensor_profile_check+sensor_read_channel+sensor_profile_list+sensor_profile_show(connect, decode and read a sensor; binding a profile stays a person's edit of config.yaml)
- warehouse —
- Agent skills — the repo ships a router skill (
skills/iaiops) plus eleven per-edition skills (iaiops-fab/iaiops-factory/iaiops-process/iaiops-building/iaiops-water/iaiops-warehouse/iaiops-clinical/iaiops-pharma/iaiops-renewables/iaiops-plcnext/iaiops-sensor) that route an agent to the right MCP server and document the tool surface.
Deployment & ecosystem fit (edge-native / Margo)
iaiops is designed to ride on a hardened, centrally-managed edge host as a portable, governed edge application — not to own the host or the fleet manager. It maps naturally onto the Margo edge-interoperability roles: the host/device is the immutable edge OS, a compliant orchestrator places workloads by desired-state, and iaiops is the OT-domain application — read-first tap + cross-protocol RCA, exposed as governed MCP tools, with an optional on-box LLM brain for a fully air-gapped diagnostic path (data never leaves the plant).
Honest status: iaiops is a natural Margo edge application but is NOT Margo-compliant yet — a container image + application description + a published conformance-toolkit result are roadmap
⏳(see docs/MARGO-ALIGNMENT.md anddocs/ROADMAP.md). No material claims Margo-compliant until that test result exists.
A container + application-description skeleton lives in deploy/margo/
(hardened Dockerfile · compose · 待核实-marked app descriptor); per-host distribution overlays
that reuse it live under deploy/ (one folder per candidate edge host).
Install
Protocol client libraries are optional extras — install only the 1–2 protocols a site actually runs (every protocol library is imported lazily; the base package installs and imports without any of them, and a call to a not-installed protocol returns a teaching error pointing at the right extra):
uv tool install "iaiops[opcua,modbus]" # just the protocols you need
# or one per site: pip install "iaiops[s7]" · everything: pip install "iaiops[all]"
# or a per-industry edition bundle: pip install "iaiops[fab]"
iaiops init # interactive: add endpoints, store passwords encrypted
iaiops doctor # config + per-protocol connectivity probe (point at simulators)
iaiops protocols # the capability map
Protocol extras: opcua · modbus · s7 · mc · fins (stdlib — pins nothing) · eip · mtconnect · sparkplug · secsgem · ethercat · profinet · bacnet · hart · iolink · bas (BAS supervisory REST — reuses the mtconnect HTTP pin) · ignition (Ignition Gateway read layer — reuses the mtconnect HTTP pin) · plus tdengine · iotdb · influxdb (historian sinks) · nats (stream egress) · ollama (on-box LLM narration) · export (Parquet) · all (every pip-installable connector).
Adapter belt (
docs/ADAPTERS.md): iaiops is a small neutral core (ingress → normalize/govern/RCA → egress) with pluggable, lazily-imported adapters — bind no store/bus/host/model, install only what a site runs. The RCA core is deterministic + cited, not a black box (docs/RCA.md); footprint is small by design (docs/FOOTPRINT.md).
Edition bundles (match the same-named IAIOPS_MCP profiles — install the protocols a vertical runs):
fab (secsgem + opcua + s7 + modbus) · factory (the discrete-manufacturing set: opcua + modbus + s7 + mc + fins + eip + mtconnect + sparkplug + ethercat + profinet + iolink + ignition) · process (opcua + modbus + hart) · building (bacnet + modbus + opcua + iolink + bas) · water (modbus + opcua + hart) · warehouse (仓储/物料搬运: eip + profinet + modbus + opcua + sparkplug) · clinical (医疗设施: bacnet + modbus + opcua) · renewables (光伏/风电: modbus + opcua + sparkplug — PV inverters (SUN2000/Growatt) + wind turbines + plant SCADA; device-level monitoring + PdM via baseline/RCA) · pharma (制药: bacnet + modbus + hart + opcua) · plcnext (opcua + modbus) · sensor (cross-protocol: modbus + iolink + sparkplug + serial — connect and decode a sensor). The grid/substation energy bundle (IEC-104/DNP3/61850) ships in iaiops-energy.
Master password
Secrets (per-endpoint passwords, MQTT credentials) are never stored in plaintext — they live in ~/.iaiops/secrets.enc (Fernet + scrypt). Export IAIOPS_MASTER_PASSWORD so the MCP server/CLI can unlock non-interactively:
export IAIOPS_MASTER_PASSWORD='…'
Example ~/.iaiops/config.yaml (one block per protocol)
endpoints:
- name: line1
protocol: opcua
endpoint_url: opc.tcp://plc.lan:4840
# username: operator # password stored encrypted via init/secret set
tags:
- { ref: "ns=2;i=5", label: temp, warn_high: 70, alarm_high: 90 }
- name: plc2
protocol: modbus
host: 10.0.0.5
port: 502
unit_id: 1
- name: press1
protocol: s7
host: 10.0.0.6
rack: 0
slot: 1 # S7-1200/1500
- name: cell3
protocol: mc
host: 10.0.0.7
port: 5007
plctype: iQ-R
- name: meter1
protocol: modbus # Modbus-RTU (serial): set transport + serial_port
transport: rtu
serial_port: /dev/ttyUSB0
baudrate: 9600
unit_id: 1
- name: omron1
protocol: fins # Omron FINS (UDP default; transport: tcp for FINS/TCP)
host: 10.0.0.11
port: 9600
- name: xmtr1
protocol: hart # HART-IP gateway (read-only; udp default / transport: tcp)
host: 10.0.0.20
- name: iolm1
protocol: iolink # IO-Link master JSON integration (read-only)
host: 10.0.0.21
flavor: iotcore # ifm IoT-Core (default) | rest (Balluff/Turck-style)
- name: vmc1
protocol: mtconnect
agent_url: http://10.0.0.8:5000
- name: uns
protocol: mqtt
host: broker.lan
use_tls: true # → port 8883
topic: spBv1.0/#
# username: edge1 # password stored encrypted
- name: cell5
protocol: ethernetip # alias: eip
host: 10.0.0.9
slot: 0 # 0 for CompactLogix; CPU slot for ControlLogix
- name: bus1
protocol: ethercat # Linux + root/CAP_NET_RAW + pip install iaiops[ethercat]
nic: eth1 # dedicated NIC cabled to the EtherCAT bus
expected_slaves: 8 # optional sanity check vs the bus scan
A key this file does not recognise is refused, not ignored — in an endpoint, in a
tag, in historian: and in retention:. A dropped key was indistinguishable from one
never written: rolle: good_count declared a production counter as far as the site was
concerned, and iaiops readiness then reported the OEE mapping unmet while pointing
nowhere near the typo. The error names the key, the accepted set, and the nearest match.
iaiops init walkthrough (per protocol)
$ iaiops init
Step 1 — master password: ********
Step 2 — add an endpoint
Endpoint name (e.g. line1): press1
Protocol ('opcua','modbus','s7','mc','mtconnect','mqtt') [opcua]: s7
S7 PLC host (IP/FQDN): 10.0.0.6
Port [102]: 102
Rack (0 for S7-1200/1500) [0]: 0
Slot (1 for S7-1200/1500, 2 for S7-300/400) [1]: 1
✓ Saved endpoint 'press1'.
(MQTT prompts add TLS/topic/username; MTConnect prompts for agent_url; EtherCAT prompts for the nic + expected_slaves and warns about the Linux/root/NIC/optional-extra requirement; OPC-UA/MQTT prompt for a hidden password stored encrypted.)
Test against a simulator (per protocol)
- OPC-UA — an
asyncuademo server (the test suite runs a real in-process one). - Modbus — ModbusPal or a
pymodbusserver simulator. - S7 — a pyS7/snap7 S7 server sim (Snap7 server) on
:102. - MTConnect — the public MTConnect demo agent, or a local agent.
- MQTT — a local
mosquittobroker (+ a Sparkplug edge for SpB topics). - Mitsubishi MC — GX Simulator / an MC 3E server sim.
- EtherNet/IP — a pycomm3-compatible CIP/Logix simulator (or a spare CompactLogix).
- Omron FINS — the in-repo mock FINS UDP/TCP responder (
tests/test_fins.py) or a spare CP/CJ PLC. - IO-Link — the in-process mock master (
tests/test_iolink.py, both JSON dialects) or any ifm/Balluff/Turck master on the bench. - EtherCAT — no simulator exists (hard-real-time, raw-Ethernet). Validate only on Linux, as root / with
CAP_NET_RAW, on a dedicated NIC wired to real slaves (e.g. a Beckhoff EK1100 coupler + EL terminals).iaiops doctorreports a clear "needs Linux/root/NIC/pysoem" status off the bus rather than failing.
Usage
Site survey — find what is on a network you have not been given a list for
Every other command needs an endpoint you already configured. scan answers the
question that comes first. It has no full-port mode, no raw sockets, no
half-open SYNs, and no write path of any kind; what it may touch is a fixed
industrial port allowlist, and how fast is capped by a ceiling the caller cannot
raise.
iaiops scan profiles # what each posture does
iaiops scan plan --targets 10.0.0.0/24 # sends NOTHING — the artifact you get signed
iaiops scan run --targets 10.0.0.0/24 --site "Line 1" \
--approved-by "J. Controls" --ticket CHG-91 \
--report survey.html # scan → store → one HTML file
iaiops scan list # stored surveys
iaiops scan report --out survey.html # re-render the latest
scan plan puts nothing on the wire. It prints every host and port that
would be touched, every class of packet that would be sent, the worst-case
duration, and the explicit list of what this tool never does — so you can run it
against a network before you have permission to scan it, and hand the output to
whoever grants that permission. scan run shows the same preview and asks once
before it sends anything (--yes to skip).
Postures run from passive (reads the local ARP cache, emits nothing at all) to
legacy-safe (reachability only, one host at a time, five connects a second —
for 1990s controllers where even a well-formed identify request is a risk).
standard and deep refuse to run without a recorded sign-off.
The HTML report is self-contained: no fonts, scripts, styles or images from anywhere, and no network request when opened. Its first section is what the scan touched — per-class emission counts, including requests that failed — followed by the list of things it never does. The device table comes after that.
From a survey to a measured OEE — the path the site actually walks
scan answers what is out there. These answer what can I do with it, and what
is the number. Each step is a real command; nothing here is a roadmap item.
iaiops readiness # contacts NOTHING
What this installation can run today, and for each thing it cannot, the specific input that is missing — ranked by how much supplying it would unlock. It touches no device, so you can run it against a site you have not been authorised to probe, which is the site that most needs the answer. It reports gaps; it never fills one in (§9.4/D16 — a guessed production counter yields a plausible-looking OEE, which is worse than an error).
iaiops collect plan line1 --duration 7d --interval-ms 1000 # contacts NOTHING
iaiops collect run line1 --duration 7d --interval-ms 1000
iaiops collect run line1 --duration 7d --resume # after a closed lid
iaiops store status # what the store holds
iaiops store prune --sealed-before 2026-08-01 --apply # refuses without a seal
A bounded assessment run — capped at 14 days, and the operator must state the end. There is deliberately no run-forever mode: a resident process on an OT network needs change management, a laptop running for a week does not (D21). Every run records the windows it could not see, so a gap is never silently readable as a stoppage.
To get an OEE out of it, three tags have to be declared — which value means running, which r