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.
Files changed (86) hide show
  1. iaiops/__init__.py +12 -0
  2. iaiops/cli/__init__.py +5 -0
  3. iaiops/cli/_common.py +56 -0
  4. iaiops/cli/_root.py +78 -0
  5. iaiops/cli/analytics.py +75 -0
  6. iaiops/cli/diagnostics.py +67 -0
  7. iaiops/cli/doctor.py +21 -0
  8. iaiops/cli/eip.py +74 -0
  9. iaiops/cli/ethercat.py +125 -0
  10. iaiops/cli/init.py +177 -0
  11. iaiops/cli/mc.py +72 -0
  12. iaiops/cli/modbus.py +82 -0
  13. iaiops/cli/mqtt.py +78 -0
  14. iaiops/cli/mtconnect.py +56 -0
  15. iaiops/cli/opcua.py +131 -0
  16. iaiops/cli/s7.py +75 -0
  17. iaiops/cli/secret.py +102 -0
  18. iaiops/connectors/__init__.py +4 -0
  19. iaiops/connectors/eip/__init__.py +1 -0
  20. iaiops/connectors/eip/ops.py +182 -0
  21. iaiops/connectors/ethercat/__init__.py +1 -0
  22. iaiops/connectors/ethercat/ops.py +422 -0
  23. iaiops/connectors/mc/__init__.py +1 -0
  24. iaiops/connectors/mc/ops.py +138 -0
  25. iaiops/connectors/modbus/__init__.py +1 -0
  26. iaiops/connectors/modbus/ops.py +202 -0
  27. iaiops/connectors/mtconnect/__init__.py +1 -0
  28. iaiops/connectors/mtconnect/ops.py +245 -0
  29. iaiops/connectors/opcua/__init__.py +1 -0
  30. iaiops/connectors/opcua/diagnostics.py +152 -0
  31. iaiops/connectors/opcua/ops.py +338 -0
  32. iaiops/connectors/s7/__init__.py +1 -0
  33. iaiops/connectors/s7/ops.py +199 -0
  34. iaiops/connectors/secsgem/__init__.py +1 -0
  35. iaiops/connectors/secsgem/ops.py +115 -0
  36. iaiops/connectors/sparkplug/__init__.py +1 -0
  37. iaiops/connectors/sparkplug/ops.py +539 -0
  38. iaiops/connectors/sparkplug/sparkplug_b_pb2.py +56 -0
  39. iaiops/core/__init__.py +6 -0
  40. iaiops/core/brain/__init__.py +1 -0
  41. iaiops/core/brain/_shared.py +34 -0
  42. iaiops/core/brain/analysis.py +167 -0
  43. iaiops/core/brain/asset_inventory.py +174 -0
  44. iaiops/core/brain/diagnostics.py +694 -0
  45. iaiops/core/brain/monitor.py +122 -0
  46. iaiops/core/brain/oee.py +269 -0
  47. iaiops/core/brain/overview.py +148 -0
  48. iaiops/core/governance/__init__.py +40 -0
  49. iaiops/core/governance/audit.py +377 -0
  50. iaiops/core/governance/budget.py +225 -0
  51. iaiops/core/governance/decorators.py +474 -0
  52. iaiops/core/governance/paths.py +45 -0
  53. iaiops/core/governance/patterns.py +378 -0
  54. iaiops/core/governance/policy.py +411 -0
  55. iaiops/core/governance/sanitize.py +39 -0
  56. iaiops/core/governance/undo.py +218 -0
  57. iaiops/core/runtime/__init__.py +1 -0
  58. iaiops/core/runtime/config.py +364 -0
  59. iaiops/core/runtime/connection.py +719 -0
  60. iaiops/core/runtime/secretstore.py +311 -0
  61. iaiops/doctor.py +231 -0
  62. iaiops-0.4.0.dist-info/METADATA +528 -0
  63. iaiops-0.4.0.dist-info/RECORD +86 -0
  64. iaiops-0.4.0.dist-info/WHEEL +4 -0
  65. iaiops-0.4.0.dist-info/entry_points.txt +3 -0
  66. iaiops-0.4.0.dist-info/licenses/LICENSE +21 -0
  67. mcp_server/__init__.py +1 -0
  68. mcp_server/_shared.py +121 -0
  69. mcp_server/profiles.py +93 -0
  70. mcp_server/server.py +56 -0
  71. mcp_server/tools/__init__.py +1 -0
  72. mcp_server/tools/analysis_tools.py +53 -0
  73. mcp_server/tools/asset_tools.py +42 -0
  74. mcp_server/tools/diagnostics_tools.py +169 -0
  75. mcp_server/tools/eip_tools.py +133 -0
  76. mcp_server/tools/ethercat_tools.py +229 -0
  77. mcp_server/tools/mc_tools.py +135 -0
  78. mcp_server/tools/modbus_tools.py +92 -0
  79. mcp_server/tools/monitor_tools.py +48 -0
  80. mcp_server/tools/mtconnect_tools.py +103 -0
  81. mcp_server/tools/oee_tools.py +88 -0
  82. mcp_server/tools/opcua_tools.py +161 -0
  83. mcp_server/tools/overview_tools.py +27 -0
  84. mcp_server/tools/s7_tools.py +154 -0
  85. mcp_server/tools/secsgem_tools.py +100 -0
  86. 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
@@ -0,0 +1,5 @@
1
+ """iaiops CLI package — exposes the assembled Typer ``app``."""
2
+
3
+ from iaiops.cli._root import app
4
+
5
+ __all__ = ["app"]
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()
@@ -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))