Industrial-AIOps

السجل الرسمي الأمن

Governed OT data tap: 15 field protocols + cross-protocol RCA brain; audit + MOC gating.

ماذا يمكنك أن تفعل مع Industrial-AIOps؟

  • 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 ...
  • Connection params: agent_url (e.g. http://host:5000).
  • Not supported / planned: MTConnect streaming (long-poll interval=); only bounded count= samples.

التثبيت

أضف هذا إلى إعدادات MCP في عميلك (Claude Desktop: claude_desktop_config.json، وCursor: ‏.cursor/mcp.json، ويستخدم VS Code وغيره الصيغة نفسها «mcpServers»).

PYPI package iaiops
Claude Code
claude mcp add industrial-aiops -e 'IAIOPS_MCP=<IAIOPS_MCP>' -e 'IAIOPS_CONFIG=<IAIOPS_CONFIG>' -e 'IAIOPS_MASTER_PASSWORD=<your iaiops_master_password>' -e 'IAIOPS_NO_EGRESS=<IAIOPS_NO_EGRESS>' -- uvx iaiops==0.32.0
إعداد JSON (Claude Desktop وCursor وVS Code)
{
    "mcpServers": {
        "industrial-aiops": {
            "command": "uvx",
            "args": [
                "iaiops==0.32.0"
            ],
            "env": {
                "IAIOPS_MCP": "<IAIOPS_MCP>",
                "IAIOPS_CONFIG": "<IAIOPS_CONFIG>",
                "IAIOPS_MASTER_PASSWORD": "<your iaiops_master_password>",
                "IAIOPS_NO_EGRESS": "<IAIOPS_NO_EGRESS>"
            }
        }
    }
}

متغيرات البيئة

  • IAIOPS_MCP إلزامي Tool-menu selection: comma-separated protocols or a named profile (e.g. 'fab', 'process', 'building', 'opcua,modbus'). No default since 0.10.0 — unset prints the selection menu and exits; set to a profile name (e.g. factory) or comma-list of protocols.
  • IAIOPS_CONFIG Path to the endpoints config YAML (default: ~/.iaiops/config.yaml).
  • IAIOPS_MASTER_PASSWORD سري Master password unlocking the encrypted secret store (~/.iaiops/secrets.enc).
  • IAIOPS_NO_EGRESS Set to 1 to withhold every data-shipping tool (message bus, external historian, remote LLM) from list_tools() at registration time, for an airgap/sealed-box deployment. This is a data-exfiltration axis only, not read/write authorisation — whether a write is allowed is the caller's decision, and every call (read or write) is audited. Gates MCP tools only — not a firewall.

التوثيق

مأخوذ من ملف README للمشروع على GitHub ويُحدَّث أسبوعيًا. تفتح الروابط على GitHub.

المحتويات
  1. Industrial-AIOps
  2. What you get
  3. Your first five minutes
  4. Why read-first
  5. Proving the analysis needs no model
  6. How far it's actually been verified
  7. 🧪 测试与共创 / Beta testing & co-creation
  8. Consolidated capability matrix
  9. Per-protocol reference
  10. Install
  11. Usage
  12. Safety & governance
  13. Roadmap
  14. License

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.32.0-factory
docker run -i --rm -v iaiops-state:/home/iaiops/.iaiops \
  ghcr.io/industrial-aiops/iaiops:0.32.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

ReadsOPC-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 outdowntime root cause (the flagship copilot), alarm floods (ISA-18.2), broken dataflows, data trustworthiness, OEE, asset inventory, legacy PLC program explainer (ST/AWL/L5X)
Governsaudit · budget · risk-tier · undo — on every call, through one engine, from both MCP and CLI
Stays yoursno 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?
Surveyiaiops scan plan → iaiops scan runpreview sends nothing; the run itemises every packet class it sent
Configureiaiops onboard draft → you merge it into config.yamlno — it reads the stored scan, and writes nothing
Tapiaiops collect run line1 --duration 7dyes — and it reports what it saw and what it missed
Declareiaiops tags export → a person fills in role → iaiops tags apply --by <you>no — the role column comes out empty on purpose
Explainiaiops oee measure --since … --until … · iaiops investigate open · iaiops diag rcano — 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.

RungWho judges our framesProtocols at this rung today
2a — real wire to a third-party serversomebody else's implementation of the specOPC-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 specthe third-party client parses our framesS7comm (pyS7) · Mitsubishi MC (pymcprotocol) · EtherNet/IP (tag layer — pycomm3, all three driver routes) · PROFINET-DCP
2c — real wire, but both ends are oursnobody independentOmron 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 devicethe devicezero, 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 <zhouwei008@gmail.com>. Either reaches a person, and a report gets answered against the current release.


Consolidated capability matrix

ProtocolToolOperationR/Wrisk_tierReturns (key fields)
OPC-UAopcua_server_infoserver statusRlowstate, product_name, namespaces
OPC-UAopcua_browsebrowse node treeRlow[{node_id, browse_name, depth}]
OPC-UAopcua_read_noderead one nodeRlowvalue, datatype, source_timestamp, good
OPC-UAopcua_read_manybatch readRlow[{node_id, value, ...}]
OPC-UAopcua_subscribe_samplebounded sampleRlow{collected, samples[]}
OPC-UAopcua_read_alarmsalarm surfacingRlow{active_alarms[], active_count}
OPC-UAopcua_read_historyHistorical Access (HDA)Rlow{supported, count, values[]}
OPC-UAopcua_diagnose_connectionconnection triageRlow{verdict, checks[]}
OPC-UAopcua_discover_tagstag auto-discovery → semantic asset modelRlow{tag_count, assets[], naming_report}
OPC-UAopcua_health_summarythreshold classify (was health_summary¹)Rlow{overall, counts, offenders[]}
OPC-UAopcua_anomaly_scanstddev outliers (was anomaly_scan¹)Rlow{mean, stddev, outliers[]}
Modbusmodbus_read_holdingFC03Rlow{raw_registers, decoded[]}
Modbusmodbus_read_inputFC04Rlow{raw_registers, decoded[]}
Modbusmodbus_read_coilsFC01Rlow{bits[]}
Modbusmodbus_read_discreteFC02Rlow{bits[]}
Modbusmodbus_detect_byte_orderbyte/word-order auto-detectRlow{best_order, candidates[]}
Modbusmodbus_list_templatesvendor register templatesRlow{templates[]}
Modbusmodbus_apply_templatedecode block via templateRlow{values:{name: engineering_value}}
Modbusmodbus_health_summarythreshold classifyRlow{overall, counts, offenders[]}
S7comms7_cpu_infoCPU id + run/stopRlow{cpu_status, cpu_info}
S7comms7_read_arearead DB/M/I/QRlow{items:[{address, value}]}
S7comms7_read_dbread data blockRlow{items:[{address, value}]}
S7comms7_read_manybatch addressesRlow{items:[{address, value}]}
S7comms7_write_dbwrite data blockWhigh/MOC{before, written, _undo_id}
Mitsubishi MCmc_cpu_statusCPU typeRlow{cpu_type, cpu_code}
Mitsubishi MCmc_read_wordsword devicesRlow{words[]}
Mitsubishi MCmc_read_bitsbit devicesRlow{bits[]}
Mitsubishi MCmc_read_manyrandom readRlow{words[], dwords[]}
Mitsubishi MCmc_write_wordswrite wordsWhigh/MOC{before, written, _undo_id}
Omron FINSfins_cpu_infocontroller data read (0501)Rlow{controller_model, controller_version}
Omron FINSfins_cpu_statuscontroller status (0601)Rlow{run_mode, status}
Omron FINSfins_read_wordsmemory-area word read (DM/CIO/W/H/A/EM)Rlow{words[]}
Omron FINSfins_read_bitsmemory-area bit readRlow{bits[]}
Omron FINSfins_read_manybatch readsRlow{items[]}
Omron FINSfins_write_wordsmemory-area writeWhigh/MOC{before, written, _undo_id}
MTConnectmtconnect_probedevice modelRlow{devices:[{components:[{data_items}]}]}
MTConnectmtconnect_currentlatest valuesRlow{observations[]}
MTConnectmtconnect_samplebounded streamRlow{observations[]}
MTConnectmtconnect_assetsassetsRlow{assets[]}
MTConnectmtconnect_oee_snapshotOEE inputsRlow{availability, execution, verdict}
MQTT/Sparkplugmqtt_read_topicbounded readRlow{messages:[{topic, payload}]}
MQTT/Sparkplugsparkplug_subscribe_samplebounded SpB sample (full decode)Rlow{samples:[{sparkplug, payload:{metrics[]}}], seq_gap_count}
MQTT/Sparkplugsparkplug_decode_payloaddecode raw SpB payloadRlow{metrics:[{name, alias, datatype, value, is_historical}]}
MQTT/Sparkplugsparkplug_node_listnode discovery + stateRlow{nodes:[{group_id, edge_node_id, online, devices}], primary_hosts[]}
MQTT/Sparkpluguns_browsetopic-tree browseRlow{topics[], tree{}}
MQTT/Sparkpluguns_topic_auditUNS naming + sprawl governanceRlow{verdict, sprawl_findings, findings{casing_collisions[], scattered_leaves[], …}}
MQTT/Sparkpluguns_schema_driftSparkplug schema-drift (baseline vs current)Rlow{verdict (none/additive/breaking), node_changes[]}
MQTT/Sparkpluguns_live_auditlive UNS audit (bounded broker sample)Rlow{verdict, findings{}}
MQTT/Sparkplugsparkplug_live_schemalive NBIRTH schema snapshotRlow{nodes[], metrics[]}
MQTT/Sparkpluguns_live_driftlive drift vs stored baselineRlow{verdict, node_changes[]}
MQTT/Sparkplugmqtt_publishpublish/commandWhigh/MOC{published_bytes, applied}
EtherNet/IPeip_controller_infoLogix controller idRlow{controller:{vendor, product_name, revision, serial}}
EtherNet/IPeip_list_tagstag discoveryRlow{tag_count, tags:[{name, data_type, structure}]}
EtherNet/IPeip_read_tagread one tag/arrayRlow{tag, value, type, good}
EtherNet/IPeip_read_manybatch readRlow{items:[{tag, value, type}]}
EtherNet/IPeip_write_tagwrite tagWhigh/MOC{before, written, _undo_id}
Diagnosticsdiagnose_dataflowlocalize no-dataRlow{verdict, diagnosis, hops[]}
Diagnosticsalarm_bad_actorsISA-18.2 floodRlow{flood_verdict, top_offenders[]}
Diagnosticstag_healthoffender rankingRlow{overall, offenders[]}
Diagnosticshistorian_healthgap/flatlineRlow{verdict, gaps[]}
Diagnosticssubscription_healthsequenced-feed loss/reorder/overloadRlow{verdict, missed_count, overloaded_channels[]}
Diagnosticsdowntime_root_causeAI downtime RCA copilot (cited, advisory)Rlow{verdict, primary_cause, hypotheses:[{cause, confidence, evidence[]}]}
Diagnosticsdowntime_root_cause_liveRCA copilot that gathers its own live evidenceRlow{…downtime_root_cause…, collected_evidence}
Diagnosticslearn_cause_weightslearn per-site RCA cause weights from labeled incidentsRlow{cause_weights{}, rationale}
Diagnosticsdata_quality_scorecardfleet data-trust rollupRlow{fleet_score, fleet_status, issue_breakdown, worst_tags[], endpoints[]}
Diagnosticsdata_quality_fleet_rollupcross-endpoint fleet viewRlow{fleet_score, endpoints[]}
Diagnosticsheartbeat_healthheartbeat/watchdog livenessRlow{alive, distinct_transitions, longest_stall_s, reason}
Alarm (ISA-18.2)alarm_flood_analysisflood episodes / chattering / stale / summaryRlow{episodes[], chattering[], stale[], summary{}}
Alarm (ISA-18.2)alarm_rationalization_worksheetCSV-exportable rationalization rowsRlow{rows[], csv_path?}
Baselinebaseline_learnconservative change-log baseline (refuses thin history)Rlow{band{p1,p99,median,mad} \insufficient_data}
Baselinebaseline_checksilent-by-default violation checkRlow{status, violations[] (cited)}
Baselinebaseline_record_changerecord operator change (restarts learning)Rlow{recorded, change_point}
Baselinebaseline_statusno_baseline / learning / ok / violationRlow{status, window}
Historianhistorian_queryread history back out of sqlite/TDengine/IoTDBRlow{rows[], truncated}
Historianhistorian_coverageper-tag row counts + first/last tsRlow{tags:[{tag, rows, first, last}]}
PLC programplc_program_outlinestructure of exported ST/AWL/L5X programRlow{blocks[], call_graph, timers[]}
PLC programplc_program_xrefsymbol/address cross-reference (cited lines)Rlow{sites:[{kind, source_file, line, quote}]}
PLC programplc_program_xref_configprogram addresses ↔ one endpoint's configured points, exact address only (never by name); optional live read of matched pointsRlow{comparable, matched, in_export_not_configured, configured_not_in_export, unmatchable}
PLC programplc_program_sectionone named block's source (≤200 lines)Rlow{text, source_file}
Exportexport_dataexport local store → CSV/SQLite/ParquetRlow{path, row_count, preview[]}
Analyticsoee_computeOEE = A×P×QRlow{availability, performance, quality, oee, oee_pct}
Analyticsdowntime_eventsstoppage detect + categorizeRlow{event_count, total_downtime_s, by_category, events[]}
Analyticsoee_multidimOEE machine×part×shiftRlow{matrix[], worst_performers[], mean_oee}
Analyticsasset_inventoryactive fingerprintRlow{assets:[{protocol, vendor, model, firmware, reachable}]}
Analyticscross_protocol_asset_modelmerge discovered tags into one asset modelRlow{assets[], tag_count}
Analyticsadopt_alias_map / diff_alias_maptag alias-map adopt/diffRlow{aliases{}, changes[]}
Analyticsmonitor_changesbounded change-of-valueRlow{change_count, changes:[{value, previous, wall_clock}]}
EtherCATethercat_master_statemaster/WKC + slave countRlow{master_state, expected_working_counter, slaves_found, slaves_expected}
EtherCATethercat_slavesbus scanRlow{slave_count, slaves:[{index, name, vendor_id, product_code, state}]}
EtherCATethercat_slave_infoslave detailRlow{sync_managers[], fmmus[], object_dictionary[], input_bytes}
EtherCATethercat_read_sdoCoE SDO uploadRlow{index, byte_length, hex, as_uint}
EtherCATethercat_read_pdoinput PDO snapshotRlow{working_counter, input_hex, input_byte_length}
EtherCATethercat_write_sdoCoE SDO downloadWhigh/MOC{before, written, applied}
EtherCATethercat_set_stateAL-state transitionWhigh/MOC{before, requested, reached, applied}
PROFINETprofinet_discoverDCP IdentifyAll (segment-wide)Rlow{station_count, stations:[{name_of_station, mac, ip, vendor_id, device_roles[]}]}
PROFINETprofinet_identify_stationidentify by name-of-stationRlow{found, name_of_station, mac, ip, device_family}
PROFINETprofinet_station_paramstargeted DCP Get (by MAC)Rlow{found, name_of_station, ip, netmask, gateway}
PROFINETprofinet_asset_inventoryDCP asset registerRlow{asset_count, io_controller_count, assets[]}
PROFINETprofinet_dcp_setDCP Set (station name / IP suite)Whigh/MOC{before, applied, _undo_id}
SECS/GEMsecsgem_equipment_statusGEM link + identity (S1F1/F2)Rlow{communication_state, are_you_there}
SECS/GEMsecsgem_list_status_variablesSVID namelist (S1F11/F12)Rlow{count, status_variables[]}
SECS/GEMsecsgem_read_status_variablesSVID values (S1F3/F4)Rlow{svids, values[]}
SECS/GEMsecsgem_list_equipment_constantsECID namelist (S2F29/F30)Rlow{count, equipment_constants[]}
SECS/GEMsecsgem_read_equipment_constantsECID values (S2F13/F14)Rlow{ecids, values[]}
SECS/GEMsecsgem_list_alarmsalarm list (S5F5/F6)Rlow{count, alarms[]}
SECS/GEMsecsgem_list_process_programsPPID directory (S7F19/F20)Rlow{count, process_programs[]}
BACnet (building)bacnet_discoverWho-Is device discoveryRlow{device_count, devices:[{device_id, address}]}
BACnet (building)bacnet_object_lista device's objectsRlow{object_count, objects:[{object_type, instance}]}
BACnet (building)bacnet_read_propertyone object propertyRlow{object_type, instance, property, value}
BACnet (building)bacnet_read_pointsall present-values (HVAC snapshot)Rlow{point_count, points:[{object_type, instance, present_value}]}
BACnet (building)bacnet_cov_subscribebounded COV capture (always unsubscribes)Rlow{notifications[], terminated_reason}
BACnet (building)bacnet_read_trend_logTrendLog readRange (bounded)Rlow{records:[{timestamp, value}]}
BACnet (building)bacnet_write_propertypresent-value write (priority)Whigh/MOC{before, written, _undo_id}
HART-IP (process)hart_device_identitycmd 0 identityRlow{manufacturer, device_type, revision}
HART-IP (process)hart_primary_variablecmd 1 PVRlow{value, unit}
HART-IP (process)hart_dynamic_variablescmd 3 PV/SV/TV/QV + loop currentRlow{variables[], loop_current}
HART-IP (process)hart_burst_samplebounded burst-variable samplingRlow{samples[]}
IO-Linkiolink_master_infomaster identityRlow{vendor, product, serial}
IO-Linkiolink_ports≤32-port sweep (mode/status/device id)Rlow{ports[]}
IO-Linkiolink_device_infoper-port device identityRlow{vendor_id, device_id, product_name}
IO-Linkiolink_read_pdinprocess-data-in (raw hex + bytes)Rlow{hex, bytes[]}
IO-Linkiolink_read_isduISDU acyclic parameter readRlow{index, subindex, value}
IO-Linkiolink_scanmaster + all connected devicesRlow{master{}, devices[]}
Serial ASCIIserial_sniffraw lines from a serial instrument (sends nothing)Rlow{endpoint, seconds, lines[], note}
Sensor (IAIOPS_MCP=sensor)sensor_import_iodddraft a profile from an IODD (待核实; returns text, writes nothing; reads only under the agent read roots — see Safety & governance)Rlow{draft, profile_id, device_name, notes[]}
Sensor (IAIOPS_MCP=sensor)sensor_profile_checkvalidate a profile file, list its channels (reads only under the agent read roots)Rlow{id, protocol, verified, channels:[{channel, layout, unit, signal, band}]}
Sensor (IAIOPS_MCP=sensor)sensor_read_channelread one point once; reachable / decodable reported separatelyRlow{levels:{reachable, decodable}, value?, unit?, band?, verified?}
Sensor (IAIOPS_MCP=sensor)sensor_profile_listlist the shipped profile library (every entry 待核实 unless it carries provenance)Rlow{profiles:[{id, protocol, verified, caveat}]}
Sensor (IAIOPS_MCP=sensor)sensor_profile_showone library profile: summary + YAML text (writes nothing)Rlow{summary, yaml, next}
BAS (Metasys/Niagara)bas_point_listsupervisory point directoryRlow{point_count, points:[{id, name, type}]}
BAS (Metasys/Niagara)bas_point_readread one supervisory pointRlow{point, value, unit, status}
BAS (Metasys/Niagara)bas_alarm_listactive controller alarmsRlow{alarm_count, alarms:[{id, priority, state}]}
BAS (Metasys/Niagara)bas_trend_readtrend/history samples (bounded)Rlow{records:[{timestamp, value}]}
BAS (Metasys/Niagara)bas_commandsupervisory command (default-OFF; life-safety object denylist refuses fire/smoke/egress/pressurization before any I/O)Whigh/MOC{before, written, _undo_id}
Ignitionignition_gateway_statusGateway + module healthRlow{state, version, modules:[{name, state}]}
Ignitionignition_tag_browsetag-tree browseRlow{tags[], tree{}}
Ignitionignition_tag_readcurrent tag valuesRlow{values:[{path, value, quality, timestamp}]}
Ignitionignition_alarm_statusactive alarmsRlow{alarm_count, alarms:[{path, priority, state}]}
Ignitionignition_tag_historytag-history query (bounded)Rlow{rows:[{path, timestamp, value}]}
信创 / compliancecompliance_mapping《工控网络安全防护指南》↔ iaiopsRlow{pillars[], status_summary, controls:[{pillar, status, gap}]}
信创 / compliancecompliance_frameworks等保 2.0 + IEC 62443 FR1–6 + GxP crosswalk; `framework="nis2"\"cra"` = NIS2 Art. 21 / CRA Annex I item by itemRlow{crosswalk[]} \{items:[{ref, coverage, mechanisms, gap}]}
信创 / compliancecompliance_dengbao_levels等保 二级 baseline vs 三级 增量Rlow{pillars:[{l2, l3_delta, status}]}
信创 / compliancecompliance_reportdeliverable compliance report (md/html); `framework="nis2"\"cra"\"eu"` for the English EU self-assessmentRlow{markdown \out_path}
信创 / compliancecompliance_evidence_bundleaudit-evidence zip (hash-chain verified)Rlow{bundle_path, manifest}
信创 / historianhistorian_pushpush telemetry to sqlite/TDengine/IoTDBR(→historian)low{sink, received, written, skipped_non_numeric}
Selfprotocols_supportedcapability mapRlow{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.)

205 governed tools = 188 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:// via asyncua (sync facade). Security: anonymous + username/password, plus application-certificate message security (Sign / SignAndEncrypt) — set client_cert + client_key (+ optional server_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_cert pinning 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 security client_cert / client_key / optional server_cert (PEM or DER paths; aliases certfile / 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_alarms browses 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 → decode hint (uint16/int16/uint32/int32/float32/raw); modbus_detect_byte_order auto-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). With sensor_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) and readiness reports it as untyped. Switching sensors (NPN / PNP / dry contact) on a DI module read as di:<addr>:<high|low> (FC02) or co:<addr>:<high|low> (FC01): the active level is required (a normally-closed switch reads 0 when actuated), the value is 1 = actuated, and readiness reports a count_source gap 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, MCP sensor_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 native libsnap7). 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 (udp default / 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 待核实.
  • 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) and rest (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 carries match: {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). With pd_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 check validates 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, extra iaiops[serial], included in iaiops[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 ONE query poll may send), and per channel a regex that must match the whole line with a value group, 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: dedicated on the endpoint (shared = an RS-485 bus that already has a master: listen only); tare / zero / calibrate are not reads and do not belong in query. Listen needs stale_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 via sensor_read_channel / iaiops sensor read; collection via iaiops 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 sniff shows 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 vs hart-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 bounded count= samples.
MQTT / Sparkplug B / UNS
  • Versions/variants: paho-mqtt — MQTT 3.1.1 & 5. Sparkplug B topic convention spBv1.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.proto generated module (depends only on protobuf). 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 the is_historical / is_null flags. 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 flags seq gaps / out-of-order. Primary-host awareness: STATE/<host_id> topics surface in sparkplug_node_list. sparkplug_decode_payload decodes a single raw payload (base64/hex) offline.
  • A data SOURCE, not just a bus (new): MQTT/Sparkplug is collectable — iaiops collect run holds 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 than stale_after_s, and — for Sparkplug — its node has not sent NDEATH/DDEATH. Anything else raises OTNoReadingError, which the collector records as a gap (collection was blind), never as downtime.
  • stale_after_s is 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 as running_when on a run_state tag.
  • 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 id mqtt live-schema reports, 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 was kill -9'd on another machine (refusal in ~2s with stale_after_s deliberately 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_info reads the controller identity.
  • Connection params: host, slot (0 for CompactLogix; the CPU slot for a ControlLogix chassis), port (44818). protocol: ethernetip (alias eip).
  • 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. pysoem is 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; alias interface), optional expected_slaves (a sanity check vs the bus scan). protocol: ethercat.
  • Operations matrix:
    ToolOpR/WriskCapture/notes
    ethercat_master_statemaster + WKC state, slave countRlowexpected vs found
    ethercat_slavesbus scan / enumerateRlowindex/vendor/product/rev/addr/AL-state
    ethercat_slave_infoone-slave detailRlowSM/FMMU + OD summary
    ethercat_read_sdoCoE SDO uploadRlowhex + uint interpretation
    ethercat_read_pdoinput PDO snapshotRlowsingle cycle, never loops
    ethercat_write_sdoCoE SDO downloadWhigh/MOCbefore-value (SDO read-back) + undo
    ethercat_set_stateAL-state transitionWhigh/MOCbefore-state + undo; can start/stop motion
  • Write/state safety: ethercat_write_sdo (hex little-endian bytes) and ethercat_set_state are 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), and profinet_asset_inventory (a register with IO-controller vs IO-device role decoding).
  • Write: profinet_dcp_set re-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-dcp is 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-dcp DCP — 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 (BAC0 over 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 (ip or ip/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/2026 is 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 tzdata needed — 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.yaml is 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, CLI iaiops 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, CLI iaiops 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]; a capped performance >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_snapshot surfaces 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 (S7 s7_cpu_info, EtherNet/IP eip_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 (asyncua read_raw_history), bounded by max_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 both duration_s (≤120) and max_changes (≤500). Read-only.
Cross-protocol brain — 0.9/0.10 additions (all read-only)
  • Conservative baseline learning (baseline_learn/check/record_change/status, CLI iaiops 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 explicit insufficient_data verdict, 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, CLI iaiops historian query|coverage) — query history back out of the sqlite/TDengine/IoTDB sinks; an optional per-site historian: 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, CLI iaiops 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 carries source_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, CLI iaiops 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 via iaiops[export]) / MCP export_data; iaiops metrics serve --port 9184 exposes Prometheus /metrics (latest tag values + counters, binds 127.0.0.1 by default) — Grafana recipe in docs/GRAFANA.md.
  • Compliance deliverables — iaiops compliance report (等保 2.0 L2/L3 status + IEC 62443 FR1–6 crosswalk + honest gap list, md/html) and iaiops compliance evidence (audit-evidence zip with hash-chain verification + manifest); MCP compliance_report / compliance_evidence_bundle. Onboarding aids, 非认证.
  • NIS2 / CRA self-assessment — iaiops compliance --framework nis2|cra and iaiops compliance report --framework nis2|cra|eu (MCP: the framework argument on compliance_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, or organisational (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 reset removes 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 earlier alarm_cascade first-out reconstruction and pdm_forecast time-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 the plc_program_outline/xref/section explainer.
  • Program ↔ field point, exact address only (plc_program_xref_config, iaiops program xref-config <file> --endpoint <name> [--live]) — which program address is which configured point: matched / in the export but not configured / configured but not named in this export. Joined only when both sides name the same address in the same address space (Siemens AWL/.scl × s7, L5X × Logix ethernetip); never by name — a label that reads like a symbol is not a match. Any other pair returns the reason it is not comparable and no lists. --live reads the matched points read-only; a failed read is a gap, never a value. iaiops program doc <file> [--endpoint <name>] --out x.html turns the same export into one self-contained, deterministic HTML document (blocks, variables, I/O list, cross-reference, call graph, change history, field cross-reference) — no LLM, nothing written back into the program.
  • Per-edition tool modules (EDITION_MODULES in mcp_server/profiles.py) — a named edition can carry its own @mcp.tool group 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)
  • 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 and docs/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 asyncua demo server (the test suite runs a real in-process one).
  • Modbus — ModbusPal or a pymodbus server 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 mosquitto broker (+ 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 doctor reports 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 register counts parts, and (for Quality) which counts good ones:

endpoints:
  - name: line1
    ideal_cycle_time_s: 0.1
    tags:
      - {ref: "0",  role: run_state,   running_when: [2]}   # 2 = running
      - {ref: "10", role: total_count}
      - {ref: "11", role: good_count}

running_when is declared, never inferred. On the 0=stopped 1=idle 2=running 3=fault status word most PLCs expose, "any non-zero means running" counts three states of four as production.

iaiops oee measure line1 --reported 97               # against the figure the site keeps
iaiops oee measure line1 --since 2026-03-02T06:00:00Z --until 2026-03-09T06:00:00Z
iaiops oee measure line1 --report oee.html --lang zh --site "一号厂区"

Scope the measurement with --since / --until, together. Without them the period is everything the store holds for that endpoint — fine for one assessment run, wrong the moment there are two: the idle weeks between a March run and an August one become one enormous blind span and neither run can be measured.

The window is charged for in full. The parts of it that were never sampled — before the first sample and after the last — count as blind exactly like a gap in the middle, so narrowing the question to the minutes that happen to have data cannot raise your coverage. Ask about a shift you observed two hours of, and the answer is 25% coverage and a refusal, not "100% of what we saw".

Availability measured over the time the collector could see, with blind windows excluded rather than counted as downtime; plus Performance, Quality and the Six Big Losses. Each factor is reported only when its inputs were declared — a partial OEE that names what is missing beats a whole one with a guess inside it.

--report writes one self-contained HTML file — no fonts, scripts, styles or images from anywhere, and no network request when opened, so it works on an air-gapped laptop and survives being forwarded as an attachment. Its first section is what the measurement could see (coverage, blind time, sample cadence), before the number, and it carries the row a sales deck usually leaves out: what had to be declared to produce each figure, and what is still missing.

iaiops case open line1 --min-stop-s 300              # one case per long stoppage
iaiops case list                                     # each carries what someone DID after it
iaiops case causes                                   # the vocabulary a confirmation may use
iaiops case confirm <id> --cause material_starvation --by wei
iaiops case agreement                                # >90% agreement is a WARNING, not a score
iaiops diag learn-weights --site default             # learn a per-site cause profile
iaiops diag rca --input bundle.json --from-case <id> # a person's answer reaches the verdict

The label is a by-product of work already being done: the audit trail already recorded that someone ran a write four minutes after the line stopped, so the case shows it. Confirmation is one choice from a fixed vocabulary, never free text, and a dismissal is a label too. Whether an answer counts as independent is derived from whether the tool had suggested it — the answerer cannot claim it.

The investigation itself — eight steps, and what each one needs

readiness answers which scenarios this site can run. This answers the next question down: if something stopped tomorrow, how far could we actually get?

iaiops investigate plan                              # contacts NOTHING

Eight evidence steps — define the incident, collect the evidence, normalize and check it, compress and rank, correlate the timeline, test the hypotheses, check against known mechanisms, conclude and close. For each one it cannot walk, it says whether that is something you have not supplied (with the command that would) or something this product cannot express at all. Those two send a person to very different places.

iaiops investigate open line1 --start <iso> --end <iso> --asset "Line 1"
iaiops investigate show <id>                         # the state it was left in
iaiops investigate list

The same eight steps over a real past window, persisted so it can be re-read and advanced later. No device is contacted — the window is already over, and its evidence is whatever was collected at the time.

Any of the three writes the forwardable version — one self-contained HTML file that opens on an air-gapped laptop in a plant office:

iaiops investigate plan --report readiness.html --lang zh
iaiops investigate open line1 --start <iso> --end <iso> --report incident.html

Unlike oee measure --report, which refuses to write a file for a refused measurement, this one writes for a blocked investigation on purpose. An OEE report is a number, so a file existing at all claims one was measured. This report's content is how far this got and what each step still needs — which makes the blocked case the one most worth handing over, and for a site nobody has instrumented yet it is the whole deliverable. What it will not do is let a blocked investigation look finished: the headline is always the walk (2 / 8), never a conclusion, and no step's own words appear above it.

Two journeys: reading devices, or subscribing to a UNS broker

Where you start depends on one question the files cannot always answer for you: is this site's data already flowing through an MQTT/UNS broker?

iaiops onboard status                  # derives the journey from config.yaml — or asks
iaiops onboard status --track devices  # no UNS yet: survey, then read PLCs directly
iaiops onboard status --track uns      # already on a broker: connect, audit, subscribe
  • devices — survey → endpoints → a point list per endpoint → what each point MEANS → collect → ask. This is also the journey for a site building a UNS: confirm what a point means before it is published, because publish-once / subscribe-many turns one wrong mapping into the same wrong number in every consumer at once.
  • uns — connect to the broker (stale_after_s is required) → audit the namespace → choose the points → what they MEAN → collect → ask. No scan: a scan never identifies MQTT, so sending a UNS site to one was a dead end. The namespace audit forks this journey into B1, audited clean, and B2, where governance — fixing the naming, and on a Sparkplug namespace watching a schema baseline with iaiops mqtt uns-live-drift — is the work rather than a later step. Plain MQTT has no drift watch: both schema commands read Sparkplug BIRTH messages.

The journey comes from config.yaml: broker endpoints only → uns, device endpoints only → devices. With nothing configured, or both kinds of endpoint, the single step is the question with both answers, and neither is picked for you. B1/B2 comes only from a stored iaiops mqtt uns-live-audit verdict — onboard contacts nothing — so until one exists it says the fork is unknown. Only an audit taken against a naming standard (--root, --min-segments), not cut short by its message cap, listening for at least one publish interval (stale_after_s), and on the broker and topic filter the endpoint uses now can make a site B1; anything less leaves the fork open and says why. Only a sprawling verdict makes it B2: a minor one is a handful of heuristic findings — a Sparkplug STATE topic trips them — so it is reported as findings to review, not as a governance verdict. An audit whose capture saw no topics is not stored: it audited nothing, and "zero findings" must not read as a clean namespace. On the UNS journey the points still go into tags: by hand; nothing turns a broker listing into a draft yet.

From a scan to a config

Everything on the path existed; nothing joined it. A site could scan forty devices and then retype all forty by hand, and nothing anywhere said which of the six commands came next.

iaiops onboard status                 # where am I, and what is the ONE next command
iaiops onboard draft                  # the newest scan → config.yaml endpoints
iaiops onboard draft --out draft.yaml # ...to a file you can review and merge

draft writes nothing into config.yaml — you merge it, exactly as with tags apply. What it emits is constrained on purpose:

  • Only CONFIRMED protocols become endpoints. An open 502 means something is listening, not that it is a Modbus device, and a config that said so would be believed. Those hosts are listed as skipped, with the reason.
  • Every value names the observation that justifies it — and the interesting half is what the scan learned that you would otherwise have hunted for: the S7 slot the CPU actually answered on, the MELSEC CPU's own plctype, whether an OPC-UA server advertises an unsecured endpoint or will need credentials.
  • A field the scan could not settle goes out commented, saying what it is waiting for — never omitted. Omission lets the protocol default apply in silence, which is how a Modbus gateway gets read at unit 1 and shows a confident number for the wrong machine.
  • tags: comes out empty. A scan finds devices; it establishes nothing about what their data means. That step is the next one, below.
  • Absence is not evidence. The draft states what it structurally cannot contain — BACnet/FINS/HART are UDP and never swept; MQTT and the supervisory layers are never identified, by design.

status is derived from the store and config.yaml every time. There is no onboarding state file, so a hand-edited config or a restored backup still gets a true answer rather than a remembered one — and a step that is genuinely done stays done even if you did the steps out of order.

For the point-list step it names the command for your protocol: opcua browse, eip tags, mtconnect probe, iolink ports, mqtt browse, bacnet objects, ethercat slaves, hart dynamic, or modbus templates. Where there genuinely is nothing to ask — an S7 CPU exposes no symbol table on the wire, and MELSEC and Omron memory carry none either — it says so in that protocol's own terms and tells you where the addresses do come from, rather than one sentence covering everything that is not OPC-UA.

Confirming what the point list means

The one thing this product refuses to infer, and until now the only way to supply it was hand-editing role: in a config file — which is exactly what stops working at a hundred rows.

iaiops tags export sheet.csv          # every monitored tag; `role` column EMPTY
# a person fills in role + running_when
iaiops tags apply sheet.csv --by wei  # prints the exact config.yaml edit

The role column comes out empty even next to a tag called GoodPartsCounter. A name is not a declaration; plenty of plants have one that counts something else, and a wrong production counter yields a plausible OEE — worse than an error (D16).

apply emits the patch rather than writing it. config.yaml stays the single source of truth: oee measure reads roles off the config tag objects, so a parallel store would let readiness call the mapping met while oee measure still could not run. A run_state with no running_when is refused here, for the same reason MonitorTag refuses it — "anything non-zero" counts idle and fault as production.

Or tick it through in a page instead of a spreadsheet:

iaiops tags page confirm.html --lang zh

HLD §13.9's App front end, delivered as a file rather than a served app. A localhost server inside an OT box has to answer which address it binds and who authenticates — and since every declaration here requires --by, a page with no identity cannot record who confirmed a tag, which is the one thing this step exists to capture. So the page collects, and the author is supplied at apply.

The page re-implements no refusal. run_state needing running_when, a ref having to be monitored, a role claimed twice — reproducing any of those in JavaScript is how they drift from the ones that actually gate the config, and a page that says "looks fine" while apply refuses is worse than no page. It ships script (it is a form) but makes no network request: it works with the cable out.

There is deliberately no MCP tool for this. An agent filling in the role column is precisely the guess D16 exists to forbid.

Two of the steps need something a person has to state:

iaiops relations declare press oven --by wei         # which asset feeds which
iaiops relations downstream press                    # nearest first

The second axis of root-cause analysis. With time alone, an upstream stoppage produces a string of equally-confident downstream false causes — on a line, downstream co-occurrence is guaranteed whatever the cause. That guarantee is why this is declared and not inferred (D25). Without it the timeline still runs; it degrades to a single asset and says so.

iaiops knowledge mount mechanisms.yaml --by wei      # what is known about this equipment
iaiops knowledge check sensor_fault --protocol modbus

A fault-mechanism library, shaped by ISO 14224: the failure mode (what you saw), the mechanism (what to go and check) and the cause (what to fix) stay separate, because they answer different questions. Entries attach to the seven taxonomy causes; they never add new ones — past roughly forty codes, two operators stop picking the same one.

It may exclude and never confirm. A mechanism that cannot apply to this equipment rules the candidate out, which is the strong move a ranker cannot make:

✗ sensor_fault — excluded
  every mounted mechanism for 'sensor_fault' applies only to hart, opcua — not to modbus

And a cause the library has never heard of reports nothing known — never "no objection". A knowledge base that knows nothing about something has not cleared it.

Program change baseline — has the approved logic moved?

A control program is a controlled document, and the usual way an undocumented change to one gets noticed is that somebody remembers. Record the version you consider approved, then ask a later export whether anything moved:

iaiops program snapshot ~/exports/Line3.scl --name Line3 --label "approved v3.2 / MOC-118"
iaiops program drift    ~/exports/Line3_today.scl --name Line3
# Over MCP the agent reads ~/exports only once the operator lists it:
#   IAIOPS_AGENT_READ_DIRS=~/exports

The snapshot stores the file's SHA-256 plus a per-block structural fingerprint — name/kind/language, declared variables, calls, branch conditions, timers — and deliberately excludes line numbers, comments and block order, so adding one comment at the top of a file does not report the whole program as changed. What is stored on disk is block names, hashes and counts; never a declaration, a source line or a comment, so the baseline store is not a second copy of your program.

Three verdicts, and each word is load-bearing:

VerdictMeans
identicalThe same SHA-256. Nothing else earns the word.
logic_changedThe extracted structure differs — reported per block, naming which of variables / calls / branches / timers_counters moved.
changed_outside_extracted_structureThe bytes differ and every block fingerprint matched.

That third one is the honest one. It is usually comments or formatting — but these parsers extract structure, they do not parse a grammar, so a real change inside a construct they do not model looks identical from here. Calling it "documentation only" would be the comfortable reading of evidence that does not support it, so it is not called that: line and comment counts are reported beside it and the verdict still says look. A drift report is a reason to read the diff, never a clearance.

iaiops program history lists what is tracked; iaiops program compare <name> <before> <after> diffs two stored snapshots. Deleting history is iaiops program forget and is CLI-only — an agent should not be one call away from removing change-control evidence. Nothing is pruned automatically. The name (not the path) is the identity, because the export directory changes every time somebody opens the engineering station and the program does not; absent --name the file stem is used and the output says so. No device is touched at any point — this reads a file a person exported.

CLI (read)
iaiops opcua read "ns=2;i=5" -e line1
iaiops modbus holding 0 -e plc2 --count 4 --decode float32
iaiops s7 read-db 1 REAL 4 -e press1 --count 2
iaiops mc words D100 -e cell3 --count 8
iaiops fins words 100 --area DM -e omron1 --count 8   # Omron FINS memory-area read
iaiops hart pv -e xmtr1                            # HART primary variable
iaiops iolink scan -e iolm1                        # IO-Link master + connected devices
iaiops mtconnect oee -e vmc1
iaiops mqtt nodes -e uns --timeout-s 15
iaiops eip tags -e cell5                           # Logix tag discovery
iaiops eip read "Conveyor.Speed" -e cell5
iaiops ethercat slaves -e bus1                     # EtherCAT bus scan (Linux+root)
iaiops ethercat read-sdo 0 4120 --subindex 1 -e bus1   # CoE SDO 0x1018:1
iaiops opcua history "ns=2;i=5" -e line1 --start 2026-06-28T08:00:00Z   # HDA
iaiops opcua monitor "ns=2;i=5" -e line1 --duration-s 20 --deadband 0.5 # CoV
iaiops diag dataflow -e line1 --ref "ns=2;i=5" --freshness-s 30
iaiops analytics oee 28800 25200 2.0 12000 11800   # OEE from five numbers you already have
                                                   # (for a measured one, see `oee measure` above)
iaiops analytics asset -e press1 -e cell5           # active asset register
CLI (write — dry-run by default, double-confirm on --apply)
iaiops s7 write-db 1 INT 0 42 -e press1            # dry-run preview
iaiops s7 write-db 1 INT 0 42 -e press1 --apply    # double-confirm prompt
iaiops mqtt publish factory/line1/cmd '{"setpoint":50}' -e uns --apply
iaiops eip write-tag Setpoint 42 -e cell5 --apply  # Logix tag write (double-confirm)
iaiops fins write-words 100 42 --area DM -e omron1 --apply  # Omron FINS write (double-confirm)
iaiops ethercat write-sdo 0 24698 e8030000 -e bus1 --apply   # CoE SDO 0x607A download
iaiops ethercat set-state PREOP --slave 0 -e bus1 --apply     # AL-state (can stop motion!)
MCP tool calls (JSON args → sample structured return)

s7_read_db:

{ "db": 1, "dtype": "REAL", "start": 4, "endpoint": "press1", "count": 2 }
{ "endpoint": "press1", "area": "DB", "db": 1, "dtype": "REAL", "start": 4,
  "count": 2, "items": [ {"address": "DB1,REAL4", "value": 20.5},
                         {"address": "DB1,REAL8", "value": 4.2} ] }

s7_write_db (dry-run):

{ "db": 1, "dtype": "INT", "start": 0, "value": 42, "endpoint": "press1" }
{ "address": "DB1,INT0", "dry_run": true, "before": 7, "would_write": 42,
  "note": "Dry run — nothing written. Re-run with dry_run=false AND a recorded approver…" }

mtconnect_oee_snapshot:

{ "availability": "AVAILABLE", "execution": "ACTIVE", "controller_mode": "AUTOMATIC",
  "program": "O1234", "available": true, "running": true, "verdict": "running" }

eip_read_tag:

{ "tag": "Conveyor.Speed", "endpoint": "cell5" }
{ "endpoint": "cell5", "tag": "Conveyor.Speed", "value": 1500.0, "type": "REAL",
  "error": "", "good": true }

eip_write_tag (dry-run):

{ "tag": "Setpoint", "value": 42, "endpoint": "cell5" }
{ "endpoint": "cell5", "tag": "Setpoint", "dry_run": true, "before": 7,
  "would_write": 42, "note": "Dry run — nothing written. Re-run with dry_run=false AND a recorded approver…" }

ethercat_read_sdo (CoE SDO upload):

{ "slave": 0, "index": 4120, "subindex": 1, "endpoint": "bus1" }
{ "endpoint": "bus1", "slave": 0, "index": "0x1018", "subindex": 1,
  "byte_length": 4, "hex": "9a020000", "as_uint": 666 }

ethercat_set_state (dry-run; can start/stop motion):

{ "state": "OP", "slave": 0, "endpoint": "bus1" }
{ "endpoint": "bus1", "scope": "slave[0]", "dry_run": true, "before": "SAFEOP",
  "would_request": "OP", "note": "Dry run — no state change. … Changing EtherCAT state can start/stop machine motion." }

sparkplug_decode_payload (full SpB metric decode):

{ "payload": "CAESBwoDYWJjEAE=", "encoding": "base64" }
{ "encoding": "sparkplug_b", "seq": 0, "metric_count": 2, "historical_count": 0,
  "metrics": [ {"name": "Temperature", "alias": 1, "datatype": "Double", "value": 21.5,
                "is_historical": false, "is_null": false} ] }

oee_compute:

{ "planned_time_s": 28800, "run_time_s": 25200, "ideal_cycle_time_s": 2.0,
  "total_count": 12000, "good_count": 11800 }
{ "availability": {"raw": 0.875, "value": 0.875, "capped": false},
  "performance": {"value": 0.952381}, "quality": {"value": 0.983333},
  "oee": 0.819444, "oee_pct": 81.94 }

asset_inventory (active fingerprint):

{ "endpoints": ["press1", "cell5"] }
{ "asset_count": 2, "reachable_count": 2, "method": "active_fingerprint",
  "assets": [ {"endpoint": "press1", "protocol": "s7", "vendor": "Siemens/compatible",
               "model": "CPU 1511-1 PN", "firmware": "2.8", "reachable": true,
               "last_seen": "2026-06-28T10:00:00+00:00"} ] }
Diagnostics (multi-dimensional JSON for an agent to visualize)

diagnose_dataflow(endpoint="line1", ref="ns=2;i=5", freshness_threshold_s=30):

{ "verdict": "comms_ok_value_stale",
  "diagnosis": "Connected with good status, but the value is STALE (age 412s > 30s) — the source/field upstream has stopped updating this point.",
  "recommended_action": "Trace upstream: the device serves the last value fine, so suspect the source/scanner/field signal that should refresh it.",
  "hops": [ {"hop":"connect","protocol":"opcua","ok":true,"detail":"OPC-UA state=0"},
            {"hop":"read_tag","ref":"ns=2;i=5","ok":true,"detail":"5.0"},
            {"hop":"freshness","evaluated":true,"stale":true,"age_seconds":412.0} ] }

alarm_bad_actors(events=[…]):

{ "event_count": 55, "window_minutes": 0.82, "alarms_per_hour": 4024.4,
  "isa_18_2": {"ok_max":6,"manageable_max":12,"flood_min":30},
  "flood_verdict": "flood",
  "priority_distribution": {"high":50,"low":5},
  "pareto_sources_for_80pct": ["FIC101"],
  "top_offenders": [ {"source":"FIC101","count":50,"share_pct":90.9,"chattering":true,"standing":false} ],
  "chattering": ["FIC101"], "standing": [] }

tag_health(tags=[…]):

{ "evaluated": 4, "overall": "alarm", "offender_count": 3,
  "offenders": [ {"ref":"hot","latest":99,"flags":["out_of_range_alarm"],"severity":3},
                 {"ref":"flat","latest":5,"flags":["flatline"],"severity":2},
                 {"ref":"bad","latest":null,"flags":["bad_quality"],"severity":3} ] }
AI downtime root-cause copilot (flagship)

downtime_root_cause correlates whatever evidence you can hand over — alarm events, tag samples, a diagnose_dataflow verdict, a machine-state series — around an incident window and returns an evidence-cited, advisory verdict. Read-first: it proposes a human-approved, MOC-gated, undoable action and executes nothing. Anti-hallucination by design — it cites only signals actually present in the input, weights them by temporal proximity to onset (a cause precedes its effect), and downgrades to insufficient_evidence (with a recommended_next_data list) rather than guessing when evidence is thin.

downtime_root_cause(window={"start":"2026-06-28T10:00:00Z","asset":"line1"}, alarms=[{"source":"M1_DRIVE","timestamp":"2026-06-28T09:59:52Z","message":"motor overload trip"}], tags=[{"ref":"DRV1.Torque","samples":[10,11,99,99],"alarm_high":80}], dataflow={"verdict":"healthy"}):

{ "window": {"start":"2026-06-28 10:00:00+00:00","asset":"line1","duration_s":300.0},
  "verdict": "root_cause_identified",
  "primary_cause": {
    "cause": "mechanical_fault", "confidence": 0.722, "confidence_band": "high",
    "evidence": [
      {"signal":"alarm","ref":"M1_DRIVE","at":"2026-06-28 09:59:52+00:00","lead_time_s":8.0,
       "detail":"motor overload trip","weight":0.4959},
      {"signal":"tag","ref":"DRV1.Torque","detail":"flags=out_of_range_alarm severity=3","weight":0.45} ],
    "recommended_action": "Dispatch maintenance to inspect the faulting unit; if a latch/interlock is set, the reversible step is to clear the fault and reset the latch (MOC-approved, undo captures the prior latch state)." },
  "evidence_summary": {"alarms_supplied":1,"tags_supplied":1,"dataflow_verdict":"healthy","total_evidence_items":2},
  "anti_hallucination": "Advisory only — nothing is executed. Every cited signal is present in the supplied evidence …" }

The same copilot is on the CLI: iaiops diag rca --input bundle.json where the bundle is {window, alarms?, tags?, dataflow?, state_series?}.

Let it gather its own evidence. downtime_root_cause_live (CLI iaiops diag rca-live) takes just an endpoint + window + the refs to look at, then pulls the evidence itself — a cross-protocol diagnose_dataflow probe, a short sampled series per ref (so flatline / bad-quality / anomaly surface via tag_health), and active OPC-UA conditions — before running the same advisory, read-only copilot. The gathered bundle is echoed back under collected_evidence (no hidden inputs):

iaiops diag rca-live -e line1 --start 2026-06-28T10:00:00Z \
  --asset line1 --ref "ns=2;i=5" --ref "ns=2;i=6"
Data-quality watchdog & UNS governance (read-only intelligence)

Two more pure-analysis layers — fully testable without live gear, and they feed the RCA copilot.

  • data_quality_scorecard (CLI iaiops diag dataquality) — a fleet data-TRUST rollup: scores each tag 0-100 on whether its data can be believed — staleness, dead heartbeat (first-class), bad-quality, flatline, gaps, anomaly — then rolls up per endpoint and across the fleet with an issue breakdown and ranked worst offenders. Distinct from process health: it asks "can I trust this number," not "is this number alarming." heartbeat_health (CLI iaiops diag heartbeat) is the standalone watchdog-liveness check (a flatlined heartbeat = dead upstream even when comms look fine).
  • uns_topic_audit (CLI iaiops mqtt uns-audit) — governs a UNS topic tree: naming conformance (allowed roots / min depth) + topic sprawl (casing collisions of the same logical name, leaf metrics scattered under many parents, depth outliers, duplicates) → a clean/minor/sprawling verdict. uns_schema_drift (CLI iaiops mqtt uns-drift) — compares two Sparkplug NBIRTH-style snapshots and classifies the change none / additive / breaking (a metric removed or its datatype changed). Positions the UNS as a governable neutral data source, not just a broker.
MCP server
IAIOPS_MCP=opcua iaiops-mcp   # stdio transport (`iaiops mcp` is equivalent)

Menu — expose only the protocols a site runs. A fab usually runs 1–2 protocols; exposing all 14 floods the model with tools it can't use. Set IAIOPS_MCP to a comma-list of protocols and/or a named profile. There is no default (since 0.10.0): a bare iaiops-mcp prints the selection menu (profiles, protocol keys, tool counts) to stderr and exits 2 instead of silently exposing 100+ tools. The cross-protocol brain (OEE / downtime / diagnostics / asset / analysis) is included by default with every selection.

IAIOPS_MCP=menu         iaiops-mcp   # print the menu (selections + tool counts)
IAIOPS_MCP=opcua,modbus iaiops-mcp   # two protocols + brain
IAIOPS_MCP=fab          iaiops-mcp   # named profile (secsgem+opcua+s7+modbus)
IAIOPS_MCP=opcua        iaiops-mcp   # effectively a single-protocol MCP
IAIOPS_MCP=all          iaiops-mcp   # everything — explicit opt-in only
                                     # (logs a tool-flood warning above 100 tools)

Named entry-point sugar. For the common single-protocol / single-edition case there is a pre-scoped console script per protocol and per named profile — no env var to set. Each is a thin shim over the same server:

iaiops-mcp-opcua     # == IAIOPS_MCP=opcua    iaiops-mcp
iaiops-mcp-modbus    # == IAIOPS_MCP=modbus   iaiops-mcp
iaiops-mcp-fab       # == IAIOPS_MCP=fab      iaiops-mcp  (per-edition)
iaiops-mcp-building  # == IAIOPS_MCP=building iaiops-mcp
iaiops-mcp-brain     # == IAIOPS_MCP=brain    iaiops-mcp  (brain only, 0 protocols)

Multi-process sites — 1 brain MCP + N protocol MCPs. Running several protocol servers side by side (e.g. iaiops-mcp-opcua + iaiops-mcp-modbus) would duplicate the ~30 brain tools in every server. Instead run one dedicated iaiops-mcp-brain and set IAIOPS_MCP_NO_BRAIN=1 on the protocol servers to strip the brain from them — the protocols_supported discovery tool stays exposed everywhere:

iaiops-mcp-brain                          # the one cross-protocol brain server
IAIOPS_MCP_NO_BRAIN=1 iaiops-mcp-opcua    # lean protocol server, no brain
IAIOPS_MCP_NO_BRAIN=1 iaiops-mcp-modbus

Write authorisation is not the tap's job. iaiops does not withhold write tools behind a server switch. Whether a write is allowed is the caller's decision — the agent's judgement or account/permission management — and the tap's job is to make that write accurate, efficient, and un-bypassably audited. Every call, read or write, on either front-end (MCP tool and iaiops CLI), goes through @governed_tool and leaves a row in ~/.iaiops/audit.db (unless the operator sets IAIOPS_AUDIT_OPT_OUT=reads, which skips successful declared reads only). Writes are additionally HIGH risk_tier and MOC-gated (dry-run + double confirmation + undo capture + a recorded approver). protocols_supported reports this posture so the model is told the rules rather than left to infer them.

Since 0.20.3 that promise is held by contract tests over the real tool surface rather than by synthetic stand-ins: every one of the ten high-risk writes is driven end to end and must be denied without an approver with the connector never reached — "it raised" only proves an exception, not that nothing reached the device. Two things those tests exposed on the way in: a call that failed was audited as ok (tools return the canonical {error, hint} envelope rather than raising, so the governance wrapper saw an ordinary return), which also told the pattern circuit breaker "success" on every failure; and the runaway guard, blind to a caller retrying a denial forever, let 500 identical denied writes through a ceiling of 10. Both are fixed and pinned.

Sealed sites — make the data-shipping tools cease to exist. IAIOPS_NO_EGRESS=1 removes every tool whose job is to transmit local or plant data to a destination the caller names, at registration time so a weak or prompt-injected model cannot call what it cannot see:

IAIOPS_NO_EGRESS=1 iaiops-mcp-factory   # 134 tools -> 129; 5 withheld

Withheld: stream_publish, stream_publish_event (NATS message bus), uns_publish (MQTT broker / Unified Namespace), historian_push (external TSDB), mqtt_publish (broker), rca_narrate (POSTs the RCA verdict — plant tags, values and citations — to a caller-supplied model base_url). This is a data-exfiltration / airgap axis, not read/write authorisation: historian_push is risk_level="low" — it changes no plant state — yet it ships telemetry off-box, so this switch withholds it. It gates MCP tools only, and is not a firewall (reads still open sockets to plant devices). protocols_supported reports each posture independently.

Scope, stated plainly — this is not a firewall:

  • It gates MCP tools only. iaiops audit forward (SIEM) is a CLI path no registry gate can reach; block it at the host if the box must be sealed.
  • Reads still open outbound sockets. iaiops is a network tap — it must talk to PLCs, brokers and historians to read anything at all. The gate removes the tools whose purpose is sending data outward, not the network itself.
  • It does not police arguments. A tool is present or absent as a whole; nothing is inspected at call time (that would be the call-time refusal this design rejects). This is why a tool with a caller-supplied destination is withheld even when its default points at localhost — the model picks the argument.
  • Local file writes are not egress: export_data and compliance_evidence_bundle stay exposed. The bytes never leave the box; getting them off it afterwards is a host-level concern.
  • Both switches cover the iaiops-mcp server only (including its per-protocol / per-edition entry-point shims). iaiops-energy-mcp is a separate server in a separate package and does not honour them yet — it mirrors in the base brain/compliance tools, so IAIOPS_NO_EGRESS=1 there still leaves historian_push, rca_narrate, stream_publish and stream_publish_event exposed on iaiops-energy 0.1.6 and earlier. Fixed in iaiops-energy 0.1.7, which pins iaiops>=0.17 for exactly this reason. Said out loud because a switch believed to be on is worse than one known to be absent.

Named profiles: all · brain · fab · factory · process · building · plcnext · water · renewables · warehouse · clinical · pharma · sensor. In an MCP client (e.g. Claude Desktop) set IAIOPS_MCP per server entry — or point the entry straight at the matching iaiops-mcp-<name> script — one entry per site/line, each a lean single- or dual-protocol server.


Safety & governance

  • Authenticated socket. Over HTTP/SSE (IAIOPS_MCP_TRANSPORT=streamable-http|sse) every request must carry Authorization: Bearer <token> once IAIOPS_MCP_TOKEN (or IAIOPS_MCP_TOKEN_FILE) is set, and a bind off loopback without a token refuses to start. The shipped containers (Margo, airgap, Siemens IE, IGEL) therefore need the token injected from the secret store; IAIOPS_ALLOWLIST_IPS is an optional second layer, off by default (never set it to loopback only behind a container port mapping: callers arrive as the bridge gateway). One shared token, not per-user identity; no TLS of its own.
  • Read-first. 190 of the 205 tools are read-only (2 of them the deprecated aliases); 4 more change nothing on a device but send data off-box (stream_publish, stream_publish_event, uns_publish, rca_narrate), and historian_push writes to a historian rather than to a device. The 10 write/command tools (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) are OT-dangerous: governed at high risk_tier, off by default (dry-run), require a double-confirm in the CLI, and a recorded approver (one-shot iaiops approve tokens; with no risk_tiers configured, high/critical operations default to the dual tier) — MOC discipline. All ten declare an undo (no exemptions since 0.20.3); a successful write captures the BEFORE value/state and registers an inverse descriptor. The inverse honestly reports "none" where none exists — a transient (retain=False) mqtt_publish cannot be unsent, and ethercat_set_state's +ERR/NONE/BOOT are not cleanly re-requestable AL-states. An undo that over-promises is worse than none, because someone will replay it onto live equipment. ethercat_set_state can START or STOP machine motion. 未经授权勿对生产控制系统写入.
  • Read/write authorisation is the caller's, not the tap's. iaiops does not encode "this server may not write" by hiding tools — that decision belongs to the agent's judgement or account/permission management. The tap's guarantee is un-bypassable audit on both front-ends: every call, read or write, via an MCP tool or the iaiops CLI, runs through @governed_tool and leaves a row in ~/.iaiops/audit.db. IAIOPS_AUDIT_OPT_OUT=reads is the one exception, set by the operator: it stops recording successful declared reads; writes, previews, egress, denials, failures and every CLI command are still recorded, and the opt-out leaves a marker row. On iaiops-mcp and the iaiops CLI the row records which front-end the call came through (origin: mcp or cli; empty — unknown, never read as human or agent — on rows written before this field existed and on servers that do not declare it), the MCP client's self-reported name, a per-session id, and, for tools with a preview flag, whether the call was a preview or an apply. Writes are additionally high risk_tier, MOC-gated, and undo-captured (see above). High/critical calls fail closed when the audit DB cannot be written.
  • No-egress mode is enforced at registration. IAIOPS_NO_EGRESS=1 withholds the 6 tools that ship data off-box (stream_publish, stream_publish_event, uns_publish, historian_push, mqtt_publish, rca_narrate), fail-closed, for airgap/sealed-box deployments. This is a data-exfiltration axis, not authorisation — historian_push is low-risk (it changes nothing) yet pushes telemetry to an external TSDB, so this switch withholds it. Which tools count is derived from @governed_tool(egress=True) metadata and guarded by an AST scan in CI, so the next egress tool cannot silently escape the gate.
  • An agent reads files only where the operator put them. The 14 MCP tools that READ a host file the agent names — sensor_import_iodd, sensor_profile_check, the seven plc_program_* file tools, device_advisory_check, the db= override of site_readiness / onboarding_status / onboarding_config_draft, and historian_push with sink="sqlite" — resolve the path (~, .., every symlink) and refuse it, unread, unless it lies under $IAIOPS_HOME, the config directory, or a directory listed in IAIOPS_AGENT_READ_DIRS (os.pathsep-separated absolute paths, e.g. ~/exports:~/advisories; set in the server's environment or ~/.iaiops/.env — no tool writes either). An entry that is relative, /, contains the home directory, is owned by another user or is writable by group or others is ignored. The refusal says nothing about the file, not even whether it exists. The CLI reads any path the operator types.
  • An agent writes files only to its own exports directory. The MCP tools that WRITE a file the agent names — export_data, compliance_report, compliance_evidence_bundle, alarm_rationalization_worksheet — resolve the path the same way and refuse it unless it lies under $IAIOPS_HOME/exports or a directory listed in IAIOPS_AGENT_WRITE_DIRS (same rules; an entry that overlaps $IAIOPS_HOME or the config directory is ignored, so the audit, undo and sample stores are never in reach). historian_push(sink="sqlite") additionally refuses the audit / undo stores and any existing database that holds tables other than samples. Before 0.31.0 these writers checked only for ..: export_data(fmt="sqlite", out_path="~/.iaiops/audit.db") replaced the audit database.
  • iaiops writes values, never PLC logic. No tool downloads, uploads, flashes or edits a PLC program — the plc_program_* tools read an exported file. The only state-changing tools are ten MOC-gated value writes (setpoints, tags, registers, properties, a state change), dry-run by default, approver-gated, with the before-value captured for undo. tests/test_no_plc_logic_writes.py fails the day that stops being true.
  • safety: sis — a safety instrumented system is never written. Mark an endpoint safety: sis and every write tool refuses it, previews included, whatever approvals exist (rule safety_sis_no_writes, audited as a denial); reads stay allowed. iaiops is not a safety system and not an IEC 61508 / 61511 tool — a SIS is changed under the plant's own functional-safety management of change.
  • Do not point this at a production control system without authorization. OT networks are safety-critical; even reads add load. Test against a simulator first.
  • All endpoint-returned text is sanitized (prompt-injection defense); secrets are never returned by any tool; MTConnect XML is parsed with DTD/entity declarations refused.
  • Every tool runs through the vendored governance harness: SQLite audit (~/.iaiops/audit.db, SHA-256 hash-chained rows + iaiops audit verify; audit fails closed for high/critical writes), token/call budget + runaway breaker, risk-tier gate (policy engine fails closed on a broken rules.yaml), undo recording. The MCP server refuses to start if any registered tool lacks the governance marker.

Roadmap

Four items that used to sit here had in fact shipped — including two this same README already listed as verified, three sections above. Listing built work as future work is the same defect as claiming unbuilt work, so they are gone: EtherNet/IP PCCC (PLC-5/SLC-500) and Micro800, passive asset discovery (iaiops scan --posture passive, ARP-cache only, emits nothing), OPC-UA certificate security, and MTConnect streaming long-poll.

What is genuinely open:

  • EtherCAT EoE / FoE / SoE mailbox protocols and full PDO-mapping decode (CoE SDO/PDO read+write and AL-state landed in 0.3.0 via the optional pysoem extra).
  • Sparkplug B Template deep expansion (DataSet landed in 0.17.0).
  • Contextual baselines — one normal band per tag today, but an OT normal range moves with shift, product and start-up. Learn per bucket, and refuse a bucket with too few samples rather than falling back to a global band, which would disguise "never seen this regime" as "this regime is normal".
  • Relationship-aware root cause, further — iaiops relations declare already gives RCA a declared line order as a second axis; what is left is using it beyond the timeline (e.g. ranking with it by default).
  • Passive discovery from a SPAN/tap — the ARP-cache posture is built; reading a mirror port is not.

Missing a protocol, device, or feature? 缺功能提 issue/PR 欢迎留言 — open a GitHub issue or PR.

License

MIT © wei

الأسئلة الشائعة

ما هو Industrial-AIOps؟

Governed OT data tap: 15 field protocols + cross-protocol RCA brain; audit + MOC gating.

كيف أثبّت Industrial-AIOps؟

يعمل على جهازك. في Claude Code: claude mcp add industrial-aiops -e 'IAIOPS_MCP=<IAIOPS_MCP>' -e 'IAIOPS_CONFIG=<IAIOPS_CONFIG>' -e 'IAIOPS_MASTER_PASSWORD=<your iaiops_master_password>' -e 'IAIOPS_NO_EGRESS=<IAIOPS_NO_EGRESS>' -- uvx iaiops==0.32.0. وفي العملاء الآخرين، الصق إعداد JSON من هذه الصفحة في إعدادات MCP الخاصة بهم.

هل يحتاج Industrial-AIOps إلى مفتاح API؟

نعم. يتطلب IAIOPS_MCP, IAIOPS_MASTER_PASSWORD، وتضبطها في إعداداته.

هل Industrial-AIOps مفتوح المصدر؟

نعم. شيفرته متاحة على GitHub بترخيص MIT.

من يصون Industrial-AIOps؟

ينشره industrial-aiops. يدرجه AI.tn لكنه لا يصونه ولا يوصي به.

من سجل MCP الرسمي. الإدراج ليس توصية: راجع شيفرة الخادم وصلاحياته قبل منحه الوصول إلى بياناتك.