iaiops 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iaiops/__init__.py +12 -0
- iaiops/cli/__init__.py +5 -0
- iaiops/cli/_common.py +56 -0
- iaiops/cli/_root.py +78 -0
- iaiops/cli/analytics.py +75 -0
- iaiops/cli/diagnostics.py +67 -0
- iaiops/cli/doctor.py +21 -0
- iaiops/cli/eip.py +74 -0
- iaiops/cli/ethercat.py +125 -0
- iaiops/cli/init.py +177 -0
- iaiops/cli/mc.py +72 -0
- iaiops/cli/modbus.py +82 -0
- iaiops/cli/mqtt.py +78 -0
- iaiops/cli/mtconnect.py +56 -0
- iaiops/cli/opcua.py +131 -0
- iaiops/cli/s7.py +75 -0
- iaiops/cli/secret.py +102 -0
- iaiops/connectors/__init__.py +4 -0
- iaiops/connectors/eip/__init__.py +1 -0
- iaiops/connectors/eip/ops.py +182 -0
- iaiops/connectors/ethercat/__init__.py +1 -0
- iaiops/connectors/ethercat/ops.py +422 -0
- iaiops/connectors/mc/__init__.py +1 -0
- iaiops/connectors/mc/ops.py +138 -0
- iaiops/connectors/modbus/__init__.py +1 -0
- iaiops/connectors/modbus/ops.py +202 -0
- iaiops/connectors/mtconnect/__init__.py +1 -0
- iaiops/connectors/mtconnect/ops.py +245 -0
- iaiops/connectors/opcua/__init__.py +1 -0
- iaiops/connectors/opcua/diagnostics.py +152 -0
- iaiops/connectors/opcua/ops.py +338 -0
- iaiops/connectors/s7/__init__.py +1 -0
- iaiops/connectors/s7/ops.py +199 -0
- iaiops/connectors/secsgem/__init__.py +1 -0
- iaiops/connectors/secsgem/ops.py +115 -0
- iaiops/connectors/sparkplug/__init__.py +1 -0
- iaiops/connectors/sparkplug/ops.py +539 -0
- iaiops/connectors/sparkplug/sparkplug_b_pb2.py +56 -0
- iaiops/core/__init__.py +6 -0
- iaiops/core/brain/__init__.py +1 -0
- iaiops/core/brain/_shared.py +34 -0
- iaiops/core/brain/analysis.py +167 -0
- iaiops/core/brain/asset_inventory.py +174 -0
- iaiops/core/brain/diagnostics.py +694 -0
- iaiops/core/brain/monitor.py +122 -0
- iaiops/core/brain/oee.py +269 -0
- iaiops/core/brain/overview.py +148 -0
- iaiops/core/governance/__init__.py +40 -0
- iaiops/core/governance/audit.py +377 -0
- iaiops/core/governance/budget.py +225 -0
- iaiops/core/governance/decorators.py +474 -0
- iaiops/core/governance/paths.py +45 -0
- iaiops/core/governance/patterns.py +378 -0
- iaiops/core/governance/policy.py +411 -0
- iaiops/core/governance/sanitize.py +39 -0
- iaiops/core/governance/undo.py +218 -0
- iaiops/core/runtime/__init__.py +1 -0
- iaiops/core/runtime/config.py +364 -0
- iaiops/core/runtime/connection.py +719 -0
- iaiops/core/runtime/secretstore.py +311 -0
- iaiops/doctor.py +231 -0
- iaiops-0.4.0.dist-info/METADATA +528 -0
- iaiops-0.4.0.dist-info/RECORD +86 -0
- iaiops-0.4.0.dist-info/WHEEL +4 -0
- iaiops-0.4.0.dist-info/entry_points.txt +3 -0
- iaiops-0.4.0.dist-info/licenses/LICENSE +21 -0
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +121 -0
- mcp_server/profiles.py +93 -0
- mcp_server/server.py +56 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/analysis_tools.py +53 -0
- mcp_server/tools/asset_tools.py +42 -0
- mcp_server/tools/diagnostics_tools.py +169 -0
- mcp_server/tools/eip_tools.py +133 -0
- mcp_server/tools/ethercat_tools.py +229 -0
- mcp_server/tools/mc_tools.py +135 -0
- mcp_server/tools/modbus_tools.py +92 -0
- mcp_server/tools/monitor_tools.py +48 -0
- mcp_server/tools/mtconnect_tools.py +103 -0
- mcp_server/tools/oee_tools.py +88 -0
- mcp_server/tools/opcua_tools.py +161 -0
- mcp_server/tools/overview_tools.py +27 -0
- mcp_server/tools/s7_tools.py +154 -0
- mcp_server/tools/secsgem_tools.py +100 -0
- mcp_server/tools/sparkplug_tools.py +182 -0
iaiops/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""iaiops — Industrial-AIOps: governed, read-first OT/industrial data tap.
|
|
2
|
+
|
|
3
|
+
A preview AI-ops tool for *reading* (and, gated, writing) industrial control
|
|
4
|
+
systems across OPC-UA, Modbus-TCP, S7comm, Mitsubishi MC, MTConnect,
|
|
5
|
+
MQTT/Sparkplug B, EtherNet/IP, EtherCAT, and SECS/GEM — plus a cross-protocol
|
|
6
|
+
intelligence layer (dataflow / connection / subscription / tag / alarm
|
|
7
|
+
diagnostics and OEE/asset analytics). Every tool runs through the shared
|
|
8
|
+
governance harness (audit / budget / risk-tier / undo). Read-first: the few
|
|
9
|
+
write/command tools are off by default (dry-run) and MOC-gated at high risk_tier.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.4.0"
|
iaiops/cli/__init__.py
ADDED
iaiops/cli/_common.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared helpers for iaiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
EndpointOption = Annotated[
|
|
16
|
+
str | None, typer.Option("--endpoint", "-e", help="Endpoint name from config")
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
21
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback."""
|
|
22
|
+
from iaiops.core.runtime.connection import OTConnectionError
|
|
23
|
+
|
|
24
|
+
return (OTConnectionError, KeyError, OSError, ValueError)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
28
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
29
|
+
|
|
30
|
+
@functools.wraps(fn)
|
|
31
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
try:
|
|
33
|
+
return fn(*args, **kwargs)
|
|
34
|
+
except (typer.Exit, typer.Abort):
|
|
35
|
+
raise
|
|
36
|
+
except _cli_error_types() as e:
|
|
37
|
+
message = str(e)
|
|
38
|
+
if isinstance(e, KeyError):
|
|
39
|
+
message = f"Missing required key: {message}"
|
|
40
|
+
console.print(f"[red]Error: {message}[/]")
|
|
41
|
+
raise typer.Exit(1) from e
|
|
42
|
+
|
|
43
|
+
return wrapper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_manager(config_path: Path | None = None):
|
|
47
|
+
"""Return a ConnectionManager built from config."""
|
|
48
|
+
from iaiops.core.runtime.config import load_config
|
|
49
|
+
from iaiops.core.runtime.connection import ConnectionManager
|
|
50
|
+
|
|
51
|
+
return ConnectionManager(load_config(config_path))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_target(endpoint: str | None):
|
|
55
|
+
"""Resolve an endpoint target by name (or the default endpoint)."""
|
|
56
|
+
return get_manager().target(endpoint)
|
iaiops/cli/_root.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Top-level Typer app: assembles sub-apps and top-level commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from iaiops.cli.analytics import analytics_app
|
|
8
|
+
from iaiops.cli.diagnostics import diag_app
|
|
9
|
+
from iaiops.cli.doctor import doctor_cmd
|
|
10
|
+
from iaiops.cli.eip import eip_app
|
|
11
|
+
from iaiops.cli.ethercat import ethercat_app
|
|
12
|
+
from iaiops.cli.init import init_cmd
|
|
13
|
+
from iaiops.cli.mc import mc_app
|
|
14
|
+
from iaiops.cli.modbus import modbus_app
|
|
15
|
+
from iaiops.cli.mqtt import mqtt_app
|
|
16
|
+
from iaiops.cli.mtconnect import mtconnect_app
|
|
17
|
+
from iaiops.cli.opcua import opcua_app
|
|
18
|
+
from iaiops.cli.s7 import s7_app
|
|
19
|
+
from iaiops.cli.secret import secret_app
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="iaiops",
|
|
23
|
+
help="Governed, vendor-neutral OT data tap + intelligent troubleshooting for "
|
|
24
|
+
"AI agents (OPC-UA / Modbus / S7comm / Mitsubishi MC / MTConnect / "
|
|
25
|
+
"MQTT-Sparkplug / EtherNet-IP / EtherCAT) + OEE/asset analytics. Read-first; "
|
|
26
|
+
"writes are MOC-gated.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
app.add_typer(opcua_app, name="opcua")
|
|
31
|
+
app.add_typer(modbus_app, name="modbus")
|
|
32
|
+
app.add_typer(s7_app, name="s7")
|
|
33
|
+
app.add_typer(mc_app, name="mc")
|
|
34
|
+
app.add_typer(mtconnect_app, name="mtconnect")
|
|
35
|
+
app.add_typer(mqtt_app, name="mqtt")
|
|
36
|
+
app.add_typer(eip_app, name="eip")
|
|
37
|
+
app.add_typer(ethercat_app, name="ethercat")
|
|
38
|
+
app.add_typer(diag_app, name="diag")
|
|
39
|
+
app.add_typer(analytics_app, name="analytics")
|
|
40
|
+
app.add_typer(secret_app, name="secret")
|
|
41
|
+
app.command("init")(init_cmd)
|
|
42
|
+
app.command("doctor")(doctor_cmd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("protocols")
|
|
46
|
+
def protocols_cmd() -> None:
|
|
47
|
+
"""Print the protocol/capability map (what iaiops supports)."""
|
|
48
|
+
import json
|
|
49
|
+
|
|
50
|
+
from rich.console import Console
|
|
51
|
+
|
|
52
|
+
from iaiops.core.brain.overview import protocols_supported
|
|
53
|
+
|
|
54
|
+
Console().print_json(json.dumps(protocols_supported(), default=str))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("mcp")
|
|
58
|
+
def mcp_cmd() -> None:
|
|
59
|
+
"""Start the MCP server (stdio transport)."""
|
|
60
|
+
import sys
|
|
61
|
+
|
|
62
|
+
if sys.version_info < (3, 11):
|
|
63
|
+
typer.echo(
|
|
64
|
+
f"ERROR: iaiops requires Python >= 3.11 "
|
|
65
|
+
f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
|
|
66
|
+
f"Fix: uv python install 3.12 && "
|
|
67
|
+
f"uv tool install --python 3.12 --force iaiops",
|
|
68
|
+
err=True,
|
|
69
|
+
)
|
|
70
|
+
raise typer.Exit(2)
|
|
71
|
+
|
|
72
|
+
from mcp_server.server import main as _mcp_main
|
|
73
|
+
|
|
74
|
+
_mcp_main()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
app()
|
iaiops/cli/analytics.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""``iaiops analytics ...`` — OEE / downtime / asset-inventory (read-only).
|
|
2
|
+
|
|
3
|
+
The OEE/downtime analyzers consume a JSON list (``--input file.json``) so the CLI
|
|
4
|
+
stays scriptable; ``asset`` actively fingerprints configured endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from iaiops.cli._common import cli_errors, get_manager
|
|
16
|
+
from iaiops.core.brain import asset_inventory as asset
|
|
17
|
+
from iaiops.core.brain import oee
|
|
18
|
+
|
|
19
|
+
analytics_app = typer.Typer(help="OEE / downtime / asset-inventory analytics (read-only).",
|
|
20
|
+
no_args_is_help=True)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _emit(data) -> None:
|
|
25
|
+
console.print_json(json.dumps(data, default=str))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_json(path: Path):
|
|
29
|
+
return json.loads(Path(path).read_text("utf-8"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@analytics_app.command("oee")
|
|
33
|
+
@cli_errors
|
|
34
|
+
def oee_cmd(
|
|
35
|
+
planned_time_s: float,
|
|
36
|
+
run_time_s: float,
|
|
37
|
+
ideal_cycle_time_s: float,
|
|
38
|
+
total_count: float,
|
|
39
|
+
good_count: float,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Compute OEE = Availability × Performance × Quality from inputs."""
|
|
42
|
+
_emit(oee.oee_compute(planned_time_s, run_time_s, ideal_cycle_time_s,
|
|
43
|
+
total_count, good_count))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@analytics_app.command("downtime")
|
|
47
|
+
@cli_errors
|
|
48
|
+
def downtime_cmd(
|
|
49
|
+
input: Path = typer.Option(..., "--input", help="JSON file: list of {timestamp, state}"),
|
|
50
|
+
min_duration_s: float = typer.Option(0.0, "--min-duration-s"),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Detect + categorize stoppages from a JSON state series."""
|
|
53
|
+
_emit(oee.downtime_events(_load_json(input), None, min_duration_s))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@analytics_app.command("oee-multidim")
|
|
57
|
+
@cli_errors
|
|
58
|
+
def oee_multidim_cmd(
|
|
59
|
+
input: Path = typer.Option(..., "--input", help="JSON file: list of labelled records"),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Aggregate OEE across dimensions (machine × part × shift) from JSON records."""
|
|
62
|
+
_emit(oee.oee_multidim(_load_json(input)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@analytics_app.command("asset")
|
|
66
|
+
@cli_errors
|
|
67
|
+
def asset_cmd(
|
|
68
|
+
endpoint: list[str] = typer.Option(None, "--endpoint", "-e",
|
|
69
|
+
help="Endpoint name (repeatable; omit = all)"),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Actively fingerprint configured endpoints into an asset register."""
|
|
72
|
+
mgr = get_manager()
|
|
73
|
+
names = list(endpoint) if endpoint else mgr.list_targets()
|
|
74
|
+
targets = [mgr.target(n) for n in names]
|
|
75
|
+
_emit(asset.asset_inventory(targets))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""``iaiops diag ...`` — cross-protocol intelligent troubleshooting (read-only).
|
|
2
|
+
|
|
3
|
+
The flood/tag/historian analyzers consume a JSON list of events/samples; pass a
|
|
4
|
+
path to a JSON file (``--input events.json``) so the CLI stays scriptable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from iaiops.cli._common import EndpointOption, cli_errors, resolve_target
|
|
16
|
+
from iaiops.core.brain import diagnostics as diag
|
|
17
|
+
|
|
18
|
+
diag_app = typer.Typer(help="Cross-protocol intelligent troubleshooting (read-only).",
|
|
19
|
+
no_args_is_help=True)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _emit(data) -> None:
|
|
24
|
+
console.print_json(json.dumps(data, default=str))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_json(path: Path):
|
|
28
|
+
return json.loads(Path(path).read_text("utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@diag_app.command("dataflow")
|
|
32
|
+
@cli_errors
|
|
33
|
+
def dataflow_cmd(
|
|
34
|
+
endpoint: EndpointOption = None,
|
|
35
|
+
ref: str = typer.Option(None, "--ref", help="Tag/node/address to read"),
|
|
36
|
+
freshness_s: int = typer.Option(60, "--freshness-s"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Localize a 'no data' break across an endpoint's reachable hops."""
|
|
39
|
+
_emit(diag.diagnose_dataflow(resolve_target(endpoint), ref, freshness_s))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@diag_app.command("alarms")
|
|
43
|
+
@cli_errors
|
|
44
|
+
def alarms_cmd(
|
|
45
|
+
input: Path = typer.Option(..., "--input", help="JSON file: list of alarm events"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""ISA-18.2 alarm-flood analysis over a JSON list of events."""
|
|
48
|
+
_emit(diag.alarm_bad_actors(_load_json(input)))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@diag_app.command("tags")
|
|
52
|
+
@cli_errors
|
|
53
|
+
def tags_cmd(
|
|
54
|
+
input: Path = typer.Option(..., "--input", help="JSON file: list of {ref, samples:[...]}"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Rank tag offenders by quality/flatline/range/anomaly over JSON samples."""
|
|
57
|
+
_emit(diag.tag_health(_load_json(input)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@diag_app.command("historian")
|
|
61
|
+
@cli_errors
|
|
62
|
+
def historian_cmd(
|
|
63
|
+
input: Path = typer.Option(..., "--input", help="JSON file: list of samples"),
|
|
64
|
+
gap_s: float = typer.Option(60.0, "--gap-s"),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Bad-tag / flatline / gap detection over a JSON sample series."""
|
|
67
|
+
_emit(diag.historian_health(_load_json(input), gap_s))
|
iaiops/cli/doctor.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Doctor top-level command: environment and connectivity check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from iaiops.cli._common import cli_errors
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cli_errors
|
|
13
|
+
def doctor_cmd(
|
|
14
|
+
skip_probe: Annotated[
|
|
15
|
+
bool, typer.Option("--skip-probe", help="Skip connectivity probe (faster)")
|
|
16
|
+
] = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Check config and OPC-UA / Modbus endpoint reachability."""
|
|
19
|
+
from iaiops.doctor import run_doctor
|
|
20
|
+
|
|
21
|
+
raise typer.Exit(run_doctor(skip_probe=skip_probe))
|
iaiops/cli/eip.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""``iaiops eip ...`` sub-commands (EtherNet/IP — Rockwell/AB Logix, read-first)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from iaiops.cli._common import EndpointOption, cli_errors, resolve_target
|
|
11
|
+
from iaiops.connectors.eip import ops
|
|
12
|
+
|
|
13
|
+
eip_app = typer.Typer(help="EtherNet/IP read-first telemetry (Allen-Bradley Logix).",
|
|
14
|
+
no_args_is_help=True)
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _emit(data) -> None:
|
|
19
|
+
console.print_json(json.dumps(data, default=str))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@eip_app.command("info")
|
|
23
|
+
@cli_errors
|
|
24
|
+
def info_cmd(endpoint: EndpointOption = None) -> None:
|
|
25
|
+
"""Show Logix controller identity (name/product/revision/serial)."""
|
|
26
|
+
_emit(ops.eip_controller_info(resolve_target(endpoint)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@eip_app.command("tags")
|
|
30
|
+
@cli_errors
|
|
31
|
+
def tags_cmd(endpoint: EndpointOption = None) -> None:
|
|
32
|
+
"""Discover the controller's tag list (names/types/structures)."""
|
|
33
|
+
_emit(ops.eip_list_tags(resolve_target(endpoint)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@eip_app.command("read")
|
|
37
|
+
@cli_errors
|
|
38
|
+
def read_cmd(tag: str, endpoint: EndpointOption = None) -> None:
|
|
39
|
+
"""Read one Logix tag (or array element)."""
|
|
40
|
+
_emit(ops.eip_read_tag(resolve_target(endpoint), tag))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@eip_app.command("read-many")
|
|
44
|
+
@cli_errors
|
|
45
|
+
def read_many_cmd(
|
|
46
|
+
tags: list[str] = typer.Argument(..., help="Logix tag names to batch-read"),
|
|
47
|
+
endpoint: EndpointOption = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Batch-read many Logix tags in one request."""
|
|
50
|
+
_emit(ops.eip_read_many(resolve_target(endpoint), tags))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@eip_app.command("write-tag")
|
|
54
|
+
@cli_errors
|
|
55
|
+
def write_tag_cmd(
|
|
56
|
+
tag: str,
|
|
57
|
+
value: str,
|
|
58
|
+
endpoint: EndpointOption = None,
|
|
59
|
+
apply: bool = typer.Option(False, "--apply", help="Actually write (omit = dry-run)"),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""[HIGH RISK] Write one value to a Logix tag (dry-run unless --apply + confirm)."""
|
|
62
|
+
target = resolve_target(endpoint)
|
|
63
|
+
if not apply:
|
|
64
|
+
_emit(ops.eip_write_tag(target, tag, value, dry_run=True))
|
|
65
|
+
return
|
|
66
|
+
console.print(
|
|
67
|
+
f"[red]OT-DANGEROUS:[/] write tag '{tag}'={value} on '{target.name}'. "
|
|
68
|
+
f"未经授权勿对生产控制系统写入."
|
|
69
|
+
)
|
|
70
|
+
if not typer.confirm("Confirm you are authorized to write to this PLC?", default=False):
|
|
71
|
+
raise typer.Abort()
|
|
72
|
+
if not typer.confirm("Final confirm — apply the write now?", default=False):
|
|
73
|
+
raise typer.Abort()
|
|
74
|
+
_emit(ops.eip_write_tag(target, tag, value, dry_run=False))
|
iaiops/cli/ethercat.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""``iaiops ethercat ...`` sub-commands (EtherCAT fieldbus, read-first).
|
|
2
|
+
|
|
3
|
+
REAL pysoem/SOEM master. Requires Linux + root/CAP_NET_RAW + a dedicated NIC +
|
|
4
|
+
real slaves (optional extra: ``pip install iaiops[ethercat]``). No simulator;
|
|
5
|
+
macOS unsupported. Reads are non-destructive; SDO write and AL-state change are
|
|
6
|
+
OT-dangerous (dry-run unless ``--apply`` + double-confirm).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from iaiops.cli._common import EndpointOption, cli_errors, resolve_target
|
|
17
|
+
from iaiops.connectors.ethercat import ops
|
|
18
|
+
|
|
19
|
+
ethercat_app = typer.Typer(
|
|
20
|
+
help="EtherCAT read-first telemetry (pysoem/SOEM; Linux+root+NIC+slaves).",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _emit(data) -> None:
|
|
27
|
+
console.print_json(json.dumps(data, default=str))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@ethercat_app.command("master")
|
|
31
|
+
@cli_errors
|
|
32
|
+
def master_cmd(endpoint: EndpointOption = None) -> None:
|
|
33
|
+
"""Show master/working-counter state and expected vs found slave count."""
|
|
34
|
+
_emit(ops.ethercat_master_state(resolve_target(endpoint)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@ethercat_app.command("slaves")
|
|
38
|
+
@cli_errors
|
|
39
|
+
def slaves_cmd(endpoint: EndpointOption = None) -> None:
|
|
40
|
+
"""Bus scan: enumerate slaves (id/vendor/product/rev/addr/state)."""
|
|
41
|
+
_emit(ops.ethercat_slaves(resolve_target(endpoint)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@ethercat_app.command("info")
|
|
45
|
+
@cli_errors
|
|
46
|
+
def info_cmd(slave: int, endpoint: EndpointOption = None) -> None:
|
|
47
|
+
"""Detail one slave: identity, SM/FMMU config, object-dictionary summary."""
|
|
48
|
+
_emit(ops.ethercat_slave_info(resolve_target(endpoint), slave))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@ethercat_app.command("read-sdo")
|
|
52
|
+
@cli_errors
|
|
53
|
+
def read_sdo_cmd(
|
|
54
|
+
slave: int,
|
|
55
|
+
index: int,
|
|
56
|
+
subindex: int = typer.Option(0, "--subindex"),
|
|
57
|
+
size: int = typer.Option(0, "--size"),
|
|
58
|
+
endpoint: EndpointOption = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""CoE SDO upload: read one object-dictionary entry (e.g. read-sdo 0 4120)."""
|
|
61
|
+
_emit(ops.ethercat_read_sdo(resolve_target(endpoint), slave, index, subindex, size))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@ethercat_app.command("read-pdo")
|
|
65
|
+
@cli_errors
|
|
66
|
+
def read_pdo_cmd(slave: int, endpoint: EndpointOption = None) -> None:
|
|
67
|
+
"""One cyclic snapshot of a slave's input process-data image."""
|
|
68
|
+
_emit(ops.ethercat_read_pdo(resolve_target(endpoint), slave))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@ethercat_app.command("write-sdo")
|
|
72
|
+
@cli_errors
|
|
73
|
+
def write_sdo_cmd(
|
|
74
|
+
slave: int,
|
|
75
|
+
index: int,
|
|
76
|
+
value: str,
|
|
77
|
+
subindex: int = typer.Option(0, "--subindex"),
|
|
78
|
+
endpoint: EndpointOption = None,
|
|
79
|
+
apply: bool = typer.Option(False, "--apply", help="Actually write (omit = dry-run)"),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""[HIGH RISK] CoE SDO download (dry-run unless --apply + confirm).
|
|
82
|
+
|
|
83
|
+
VALUE is a hex string of the raw little-endian bytes (e.g. 'e803' = 1000 u16).
|
|
84
|
+
"""
|
|
85
|
+
target = resolve_target(endpoint)
|
|
86
|
+
if not apply:
|
|
87
|
+
_emit(ops.ethercat_write_sdo(target, slave, index, value, subindex, dry_run=True))
|
|
88
|
+
return
|
|
89
|
+
console.print(
|
|
90
|
+
f"[red]OT-DANGEROUS:[/] write SDO 0x{index:04X}:{subindex}=0x{value} on "
|
|
91
|
+
f"slave {slave} of '{target.name}'. 未经授权勿对生产控制系统写入."
|
|
92
|
+
)
|
|
93
|
+
if not typer.confirm("Confirm you are authorized to write to this bus?", default=False):
|
|
94
|
+
raise typer.Abort()
|
|
95
|
+
if not typer.confirm("Final confirm — apply the SDO write now?", default=False):
|
|
96
|
+
raise typer.Abort()
|
|
97
|
+
_emit(ops.ethercat_write_sdo(target, slave, index, value, subindex, dry_run=False))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@ethercat_app.command("set-state")
|
|
101
|
+
@cli_errors
|
|
102
|
+
def set_state_cmd(
|
|
103
|
+
state: str,
|
|
104
|
+
slave: int = typer.Option(-1, "--slave", help="Slave index, or -1 for the master"),
|
|
105
|
+
endpoint: EndpointOption = None,
|
|
106
|
+
apply: bool = typer.Option(False, "--apply", help="Actually transition (omit = dry-run)"),
|
|
107
|
+
) -> None:
|
|
108
|
+
"""[HIGH RISK] Request an AL-state transition (dry-run unless --apply + confirm).
|
|
109
|
+
|
|
110
|
+
Changing EtherCAT state can START or STOP machine motion. STATE = INIT|PREOP|SAFEOP|OP.
|
|
111
|
+
"""
|
|
112
|
+
target = resolve_target(endpoint)
|
|
113
|
+
if not apply:
|
|
114
|
+
_emit(ops.ethercat_set_state(target, state, slave, dry_run=True))
|
|
115
|
+
return
|
|
116
|
+
scope = "master" if slave < 0 else f"slave {slave}"
|
|
117
|
+
console.print(
|
|
118
|
+
f"[red]OT-DANGEROUS:[/] request state '{state}' on {scope} of '{target.name}'. "
|
|
119
|
+
f"This can START or STOP machine motion. 未经授权勿对生产控制系统写入."
|
|
120
|
+
)
|
|
121
|
+
if not typer.confirm("Confirm you are authorized to change bus state?", default=False):
|
|
122
|
+
raise typer.Abort()
|
|
123
|
+
if not typer.confirm("Final confirm — apply the state change now?", default=False):
|
|
124
|
+
raise typer.Abort()
|
|
125
|
+
_emit(ops.ethercat_set_state(target, state, slave, dry_run=False))
|