licos-platform-cli 0.3.2__tar.gz → 0.4.0__tar.gz

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.

Potentially problematic release.


This version of licos-platform-cli might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-platform-cli
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: LICOS platform CLI
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: licos-platform-sdk>=0.4.2
6
+ Requires-Dist: licos-platform-sdk>=0.5.0
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-platform-cli"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "LICOS platform CLI"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-platform-sdk>=0.4.2",
11
+ "licos-platform-sdk>=0.5.0",
12
12
  ]
13
13
 
14
14
  [project.scripts]
@@ -508,10 +508,10 @@ UNS_HELP = {
508
508
  "discover, validate, plan, and finalize are read-only for external systems; apply performs writes.",
509
509
  "Run apply only after reviewing the current plan, and pass its exact documentHash and planHash with --confirm.",
510
510
  "UNS.json must not contain platform IDs, external IDs, credentials, MQTT infrastructure IDs, or credential-bearing RTSP URLs.",
511
- "Use resource-search before reference bindings; a reference selector must resolve exactly one existing resource.",
512
- "Point nodes are message-backed. New manifests and changes do not create point storage; existing point storage remains unchanged and does not block point operations.",
513
- "Use UNSChangeSet for supported isolated structural changes. Remove storage only on an explicit confirmed request. Unsupported operations must fail; do not regenerate a full manifest or call external services directly.",
514
- "Use either a current-project node code or --path for business access. Data writes/deletions, point control, and video control require explicit confirmation.",
511
+ "Use resource-search before reference bindings; a reference selector must resolve exactly one existing resource.",
512
+ "Point nodes are message-backed. New manifests and changes do not create point storage; existing point storage remains unchanged and does not block point operations.",
513
+ "Use UNSChangeSet for supported isolated structural changes. Remove storage only on an explicit confirmed request. Unsupported operations must fail; do not regenerate a full manifest or call external services directly.",
514
+ "Use either a current-project node code or --path for business access. Data writes/deletions, point control, and video control require explicit confirmation.",
515
515
  ],
516
516
  "sdk": {
517
517
  "python": "from licos_platform_sdk import uns\nplanned = uns.plan(document)\nresult = uns.apply(document, expected_document_hash=planned['documentHash'], expected_plan_hash=planned['planHash'], confirmed=True)",
@@ -583,7 +583,7 @@ UNS_HELP = {
583
583
  "parameters": [{"name": "--file", "required": True, "description": "Local UNS JSON file."}],
584
584
  "example": "licos-platform uns plan --file UNS.json",
585
585
  },
586
- "apply": {
586
+ "apply": {
587
587
  "description": "Execute a previously reviewed plan after explicit confirmation and hash verification.",
588
588
  "parameters": [
589
589
  {"name": "--file", "required": True, "description": "The exact local UNS JSON document that was planned."},
@@ -591,62 +591,89 @@ UNS_HELP = {
591
591
  {"name": "--plan-hash", "required": True, "description": "planHash returned by the reviewed plan."},
592
592
  {"name": "--confirm", "required": True, "description": "Explicitly confirm external writes."},
593
593
  ],
594
- "example": "licos-platform uns apply --file UNS.json --document-hash <documentHash> --plan-hash <planHash> --confirm",
595
- },
596
- "changes-validate": {
597
- "description": "Validate one strict UNSChangeSet document without writes.",
598
- "parameters": [{"name": "--file", "required": True, "description": "Local UNSChangeSet JSON file."}],
599
- "example": "licos-platform uns changes-validate --file UNS.change.json",
600
- },
601
- "changes-plan": {
602
- "description": "Resolve targets and create a read-only plan for one UNSChangeSet.",
603
- "parameters": [{"name": "--file", "required": True, "description": "Local UNSChangeSet JSON file."}],
604
- "example": "licos-platform uns changes-plan --file UNS.change.json",
605
- },
606
- "changes-apply": {
607
- "description": "Apply the exact reviewed UNSChangeSet; destructive or replacement actions require impact confirmation.",
608
- "parameters": [
609
- {"name": "--file", "required": True, "description": "The exact UNSChangeSet document that was planned."},
610
- {"name": "--document-hash", "required": True, "description": "documentHash returned by changes-plan."},
611
- {"name": "--plan-hash", "required": True, "description": "planHash returned by changes-plan."},
612
- {"name": "--confirm", "required": True, "description": "Confirm the planned writes."},
613
- {"name": "--confirm-impact", "required": False, "description": "Additionally confirm delete, move, replacement, or rebind impact."},
614
- ],
615
- "example": "licos-platform uns changes-apply --file UNS.change.json --document-hash <documentHash> --plan-hash <planHash> --confirm --confirm-impact",
616
- },
617
- "node-update": {
618
- "description": "Build and plan one supported node metadata update; this command never applies it.",
619
- "parameters": [
620
- {"name": "--path / --node-code", "required": True, "description": "Exactly one project-local target locator."},
621
- {"name": "--set-json", "required": True, "description": "Supported node fields to replace."},
622
- ],
623
- "example": "licos-platform uns node-update --path project:/LINE_1/TEMP_1 --set-json '{\"name\":\"炉温\"}'",
624
- },
625
- "node-move": {
626
- "description": "Build and plan one project-local node move; this command never applies it.",
627
- "parameters": [
628
- {"name": "--path / --node-code", "required": True, "description": "Exactly one target locator."},
629
- {"name": "--parent-path / --parent-node-code", "required": True, "description": "Exactly one destination locator."},
630
- ],
631
- "example": "licos-platform uns node-move --node-code TEMP_1 --parent-path project:/LINE_2",
632
- },
633
- "point-rebind": {
634
- "description": "Build and plan rebinding one point to one exact existing collection point; this command never applies it.",
635
- "parameters": [
636
- {"name": "--path / --node-code", "required": True, "description": "Exactly one point target locator."},
637
- {"name": "--selector-json", "required": True, "description": "Exact tagCode, taskName, and protocol selector."},
638
- {"name": "--field-mappings-json", "required": False, "description": "Optional field mapping array."},
639
- ],
640
- "example": "licos-platform uns point-rebind --node-code TEMP_1 --selector-json '{\"tagCode\":\"T1\",\"taskName\":\"OPC采集\",\"protocol\":\"opcua\"}'",
641
- },
642
- "directory-create": {
643
- "description": "Build and plan creating one project-local grouping directory; this command never applies it.",
644
- "parameters": [
645
- {"name": "--parent-path / --parent-node-code", "required": True, "description": "Exactly one parent locator."},
646
- {"name": "--node-json", "required": True, "description": "Directory name, stable code, and optional metadata."},
647
- ],
648
- "example": "licos-platform uns directory-create --parent-path project:/ --node-json '{\"name\":\"一号产线\",\"code\":\"LINE_1\"}'",
649
- },
594
+ "example": "licos-platform uns apply --file UNS.json --document-hash <documentHash> --plan-hash <planHash> --confirm",
595
+ },
596
+ "changes-validate": {
597
+ "description": "Validate one strict UNSChangeSet document without writes.",
598
+ "parameters": [{"name": "--file", "required": True, "description": "Local UNSChangeSet JSON file."}],
599
+ "example": "licos-platform uns changes-validate --file UNS.change.json",
600
+ },
601
+ "changes-plan": {
602
+ "description": "Resolve targets and create a read-only plan for one UNSChangeSet.",
603
+ "parameters": [{"name": "--file", "required": True, "description": "Local UNSChangeSet JSON file."}],
604
+ "example": "licos-platform uns changes-plan --file UNS.change.json",
605
+ },
606
+ "changes-apply": {
607
+ "description": "Apply the exact reviewed UNSChangeSet; destructive or replacement actions require impact confirmation.",
608
+ "parameters": [
609
+ {"name": "--file", "required": True, "description": "The exact UNSChangeSet document that was planned."},
610
+ {"name": "--document-hash", "required": True, "description": "documentHash returned by changes-plan."},
611
+ {"name": "--plan-hash", "required": True, "description": "planHash returned by changes-plan."},
612
+ {"name": "--confirm", "required": True, "description": "Confirm the planned writes."},
613
+ {"name": "--confirm-impact", "required": False, "description": "Additionally confirm delete, move, replacement, or rebind impact."},
614
+ ],
615
+ "example": "licos-platform uns changes-apply --file UNS.change.json --document-hash <documentHash> --plan-hash <planHash> --confirm --confirm-impact",
616
+ },
617
+ "node-update": {
618
+ "description": "Build and plan one supported node metadata update; this command never applies it.",
619
+ "parameters": [
620
+ {"name": "--path / --node-code", "required": True, "description": "Exactly one project-local target locator."},
621
+ {"name": "--set-json", "required": True, "description": "Supported node fields to replace."},
622
+ ],
623
+ "example": "licos-platform uns node-update --path project:/LINE_1/TEMP_1 --set-json '{\"name\":\"炉温\"}'",
624
+ },
625
+ "node-move": {
626
+ "description": "Build and plan one project-local node move; this command never applies it.",
627
+ "parameters": [
628
+ {"name": "--path / --node-code", "required": True, "description": "Exactly one target locator."},
629
+ {"name": "--parent-path / --parent-node-code", "required": True, "description": "Exactly one destination locator."},
630
+ ],
631
+ "example": "licos-platform uns node-move --node-code TEMP_1 --parent-path project:/LINE_2",
632
+ },
633
+ "point-rebind": {
634
+ "description": "Build and plan rebinding one point to one exact existing collection point; this command never applies it.",
635
+ "parameters": [
636
+ {"name": "--path / --node-code", "required": True, "description": "Exactly one point target locator."},
637
+ {"name": "--selector-json", "required": True, "description": "Exact tagCode, taskName, and protocol selector."},
638
+ {"name": "--field-mappings-json", "required": False, "description": "Optional field mapping array."},
639
+ ],
640
+ "example": "licos-platform uns point-rebind --node-code TEMP_1 --selector-json '{\"tagCode\":\"T1\",\"taskName\":\"OPC采集\",\"protocol\":\"opcua\"}'",
641
+ },
642
+ "directory-create": {
643
+ "description": "Build and plan creating one project-local grouping directory; this command never applies it.",
644
+ "parameters": [
645
+ {"name": "--parent-path / --parent-node-code", "required": True, "description": "Exactly one parent locator."},
646
+ {"name": "--node-json", "required": True, "description": "Directory name, stable code, and optional metadata."},
647
+ ],
648
+ "example": "licos-platform uns directory-create --parent-path project:/ --node-json '{\"name\":\"一号产线\",\"code\":\"LINE_1\"}'",
649
+ },
650
+ "connection-update": {
651
+ "description": "生成并规划一个当前项目托管采集连接的增量修改,不直接执行写入。",
652
+ "parameters": [
653
+ {"name": "--selector-json", "required": True, "description": "精确的 name、protocol 选择器。"},
654
+ {"name": "--set-json", "required": True, "description": "name、credentialRef 或 configPatch。"},
655
+ {"name": "--output", "required": False, "description": "在项目目录内保存生成的 UNSChangeSet。"},
656
+ ],
657
+ "example": "licos-platform uns connection-update --selector-json '{\"name\":\"一号PLC\",\"protocol\":\"modbus-tcp\"}' --set-json '{\"configPatch\":{\"host\":\"10.0.0.2\"}}' --output UNS.change.json",
658
+ },
659
+ "collection-task-update": {
660
+ "description": "生成并规划一个当前项目托管采集任务的增量修改,不直接执行写入。",
661
+ "parameters": [
662
+ {"name": "--selector-json", "required": True, "description": "精确的 name、protocol、connectionName 选择器。"},
663
+ {"name": "--set-json", "required": True, "description": "name、pollIntervalMs、enabled 或 configPatch。"},
664
+ {"name": "--output", "required": False, "description": "在项目目录内保存生成的 UNSChangeSet。"},
665
+ ],
666
+ "example": "licos-platform uns collection-task-update --selector-json '{\"name\":\"温度采集\",\"protocol\":\"modbus-tcp\",\"connectionName\":\"一号PLC\"}' --set-json '{\"pollIntervalMs\":2000}' --output UNS.change.json",
667
+ },
668
+ "point-config-update": {
669
+ "description": "生成并规划一个当前项目托管采集点位配置的增量修改,不改变点位编码和所属任务。",
670
+ "parameters": [
671
+ {"name": "--selector-json", "required": True, "description": "精确的 tagCode、taskName、protocol 选择器。"},
672
+ {"name": "--set-json", "required": True, "description": "点位通用字段或协议 addressPatch。"},
673
+ {"name": "--output", "required": False, "description": "在项目目录内保存生成的 UNSChangeSet。"},
674
+ ],
675
+ "example": "licos-platform uns point-config-update --selector-json '{\"tagCode\":\"TEMP_01\",\"taskName\":\"温度采集\",\"protocol\":\"modbus-tcp\"}' --set-json '{\"addressPatch\":{\"address\":\"40002\"}}' --output UNS.change.json",
676
+ },
650
677
  "finalize": {
651
678
  "description": "Validate and atomically install a candidate as project-root UNS.json, revalidate it, and return a read-only plan.",
652
679
  "parameters": [{"name": "--file", "required": True, "description": "Candidate JSON file inside LICOS_PROJECT_PATH."}],
@@ -740,11 +767,116 @@ UNS_HELP = {
740
767
  }
741
768
 
742
769
 
743
- HELP_TOPICS = {
770
+ INDUSTRIAL_HELP = {
771
+ "topic": "industrial",
772
+ "description": "Project-scoped direct collection and video integration without creating UNS nodes.",
773
+ "runtime": RUNTIME_ENV,
774
+ "rules": [
775
+ "Use only when the user explicitly requests direct collector access or explicitly says not to create UNS.",
776
+ "CLI and SDK call platform APIs; never request or expose IOTDATA API keys or MQTT credentials.",
777
+ "Search before create. Update and delete require the versionHash returned by resource-get/search.",
778
+ "Apply requires reviewed documentHash and planHash. Delete also requires --confirm-impact.",
779
+ "Realtime defaults to WebSocket; MQTT must be selected explicitly. Point control defaults to HTTP; MQTT must be selected explicitly.",
780
+ "Transport failures do not silently switch to another transport.",
781
+ ],
782
+ "resourceTypes": [
783
+ "edgeAgent (read-only)",
784
+ "connection",
785
+ "collectionTask",
786
+ "collectionPoint",
787
+ "videoEdge",
788
+ "videoGateway",
789
+ "videoDevice",
790
+ "videoChannel",
791
+ ],
792
+ "commands": {
793
+ "protocol-schema": {
794
+ "description": "Get required and default fields for one collection protocol.",
795
+ "parameters": [{"name": "protocol", "required": True, "description": "Protocol code."}],
796
+ "example": "licos-platform industrial protocol-schema modbus-tcp",
797
+ },
798
+ "resource-search": {
799
+ "description": "Search project-managed or enterprise collection/video resources.",
800
+ "parameters": [
801
+ {"name": "--resource-type", "required": True, "description": "Allowlisted resource type."},
802
+ {"name": "--scope", "required": False, "description": "project or enterprise."},
803
+ {"name": "--protocol", "required": False, "description": "Collection protocol filter."},
804
+ {"name": "--keyword", "required": False, "description": "Name/code/address keyword."},
805
+ {"name": "--selector-json", "required": False, "description": "Exact field selector object."},
806
+ ],
807
+ "example": "licos-platform industrial resource-search --resource-type collectionPoint --scope enterprise --keyword TEMP",
808
+ },
809
+ "resource-get": {
810
+ "description": "Get one resource, writable state, and concurrency versionHash.",
811
+ "parameters": [
812
+ {"name": "--resource-type", "required": True, "description": "Allowlisted resource type."},
813
+ {"name": "--resource-id", "required": True, "description": "Resource ID."},
814
+ ],
815
+ "example": "licos-platform industrial resource-get --resource-type collectionPoint --resource-id point-id",
816
+ },
817
+ "changes-validate / changes-plan / changes-apply": {
818
+ "description": "Validate, review, and apply an IndustrialChangeSet JSON file.",
819
+ "parameters": [
820
+ {"name": "--file", "required": True, "description": "IndustrialChangeSet JSON file."},
821
+ {"name": "--document-hash / --plan-hash", "required": True, "description": "Required only by apply."},
822
+ {"name": "--confirm", "required": True, "description": "Required only by apply."},
823
+ {"name": "--confirm-impact", "required": False, "description": "Required when the plan contains delete impact."},
824
+ ],
825
+ "example": "licos-platform industrial changes-plan --file ./industrial.change.json",
826
+ },
827
+ "resource-create / resource-update / resource-delete": {
828
+ "description": "Generate and plan one operation; these commands do not apply writes.",
829
+ "parameters": [
830
+ {"name": "--resource-type", "required": True, "description": "Allowlisted resource type."},
831
+ {"name": "--spec-json / --set-json", "required": False, "description": "Create or update fields."},
832
+ {"name": "--resource-id / --version-hash", "required": False, "description": "Required by update/delete."},
833
+ {"name": "--output", "required": False, "description": "Write the generated ChangeSet under LICOS_PROJECT_PATH."},
834
+ ],
835
+ "example": "licos-platform industrial resource-update --resource-type collectionPoint --resource-id point-id --version-hash HASH --set-json '{\"name\":\"炉温\"}'",
836
+ },
837
+ "realtime-config": {
838
+ "description": "Build the platform WebSocket URL and subscribe message for application code.",
839
+ "parameters": [
840
+ {"name": "--point-id / --tag-code", "required": True, "description": "At least one selector; repeatable."},
841
+ {"name": "--transport", "required": False, "description": "auto/websocket defaults to WebSocket; mqtt is explicit."},
842
+ ],
843
+ "example": "licos-platform industrial realtime-config --point-id point-id --transport websocket",
844
+ },
845
+ "point-current / point-history": {
846
+ "description": "Read current or historical point values.",
847
+ "parameters": [{"name": "point_id", "required": True, "description": "Collection point ID."}],
848
+ "example": "licos-platform industrial point-current point-id",
849
+ },
850
+ "point-write": {
851
+ "description": "Dry-run or control a writable project-managed point.",
852
+ "parameters": [
853
+ {"name": "point_id", "required": True, "description": "Collection point ID."},
854
+ {"name": "--value-json", "required": True, "description": "JSON scalar/object value."},
855
+ {"name": "--reason / --ticket", "required": True, "description": "Audit reason and ticket."},
856
+ {"name": "--transport", "required": False, "description": "auto/http defaults to HTTP; mqtt is explicit."},
857
+ {"name": "--dry-run / --confirm", "required": True, "description": "One safety mode is required."},
858
+ ],
859
+ "example": "licos-platform industrial point-write point-id --value-json 80 --reason 调试 --ticket T-1 --confirm",
860
+ },
861
+ "video-stream / video-start / video-stop": {
862
+ "description": "Get playback options or explicitly start/stop a project-managed video preview.",
863
+ "parameters": [
864
+ {"name": "channel_id", "required": True, "description": "Video channel ID."},
865
+ {"name": "--protocol", "required": False, "description": "Start defaults to http-flv."},
866
+ {"name": "--confirm", "required": True, "description": "Required by start/stop."},
867
+ ],
868
+ "example": "licos-platform industrial video-start channel-id --protocol http-flv --confirm",
869
+ },
870
+ },
871
+ }
872
+
873
+
874
+ HELP_TOPICS = {
744
875
  "storage": STORAGE_HELP,
745
876
  "database": DATABASE_HELP,
746
877
  "knowledge": KNOWLEDGE_HELP,
747
- "oauth": OAUTH_HELP,
878
+ "oauth": OAUTH_HELP,
879
+ "industrial": INDUSTRIAL_HELP,
748
880
  "uns": UNS_HELP,
749
881
  }
750
882
 
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from licos_platform_sdk import industrial
11
+
12
+
13
+ Handler = Callable[[argparse.Namespace], Any]
14
+
15
+
16
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
17
+ parser.set_defaults(handler=handler)
18
+
19
+
20
+ def _json(value: str, label: str) -> Any:
21
+ try:
22
+ return json.loads(value)
23
+ except json.JSONDecodeError as exc:
24
+ raise argparse.ArgumentTypeError(f"{label} must be valid JSON: {exc.msg}") from exc
25
+
26
+
27
+ def _json_object(value: str | None, label: str) -> dict[str, Any]:
28
+ if value is None:
29
+ return {}
30
+ payload = _json(value, label)
31
+ if not isinstance(payload, dict):
32
+ raise argparse.ArgumentTypeError(f"{label} must be a JSON object")
33
+ return payload
34
+
35
+
36
+ def _document(path: str) -> dict[str, Any]:
37
+ try:
38
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
39
+ except OSError as exc:
40
+ raise argparse.ArgumentTypeError(f"cannot read industrial change file: {exc}") from exc
41
+ except json.JSONDecodeError as exc:
42
+ raise argparse.ArgumentTypeError(
43
+ f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"
44
+ ) from exc
45
+ if not isinstance(payload, dict):
46
+ raise argparse.ArgumentTypeError("industrial change document root must be an object")
47
+ return payload
48
+
49
+
50
+ def _change_set(operation: dict[str, Any]) -> dict[str, Any]:
51
+ return {
52
+ "schemaVersion": "1.0",
53
+ "kind": "IndustrialChangeSet",
54
+ "operations": [operation],
55
+ }
56
+
57
+
58
+ def _plan_operation(operation: dict[str, Any], output: str | None) -> Any:
59
+ document = _change_set(operation)
60
+ plan = industrial.plan_changes(document)
61
+ if output is None:
62
+ return plan
63
+ output_path = _write_document(output, document)
64
+ return {"changeSetFile": str(output_path), "plan": plan}
65
+
66
+
67
+ def _write_document(path: str, document: dict[str, Any]) -> Path:
68
+ project_root = Path(os.environ.get("LICOS_PROJECT_PATH", "/workspace/projects")).resolve()
69
+ output = Path(path).resolve()
70
+ if not output.is_relative_to(project_root):
71
+ raise argparse.ArgumentTypeError("change-set output must be inside LICOS_PROJECT_PATH")
72
+ output.parent.mkdir(parents=True, exist_ok=True)
73
+ payload = (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
74
+ with tempfile.NamedTemporaryFile(
75
+ mode="wb", prefix=".industrial.change.", suffix=".tmp", dir=output.parent, delete=False
76
+ ) as staging:
77
+ staging.write(payload)
78
+ staging.flush()
79
+ os.fsync(staging.fileno())
80
+ staging_path = Path(staging.name)
81
+ os.replace(staging_path, output)
82
+ return output
83
+
84
+
85
+ def _cmd_protocol_schema(args: argparse.Namespace) -> Any:
86
+ return industrial.protocol_schema(args.protocol)
87
+
88
+
89
+ def _cmd_resource_search(args: argparse.Namespace) -> Any:
90
+ return industrial.search_resources(
91
+ args.resource_type,
92
+ protocol=args.protocol,
93
+ keyword=args.keyword,
94
+ scope=args.scope,
95
+ selector=_json_object(args.selector_json, "selector"),
96
+ page=args.page,
97
+ page_size=args.page_size,
98
+ )
99
+
100
+
101
+ def _cmd_resource_get(args: argparse.Namespace) -> Any:
102
+ return industrial.resource(args.resource_type, args.resource_id)
103
+
104
+
105
+ def _cmd_changes_validate(args: argparse.Namespace) -> Any:
106
+ return industrial.validate_changes(_document(args.file))
107
+
108
+
109
+ def _cmd_changes_plan(args: argparse.Namespace) -> Any:
110
+ return industrial.plan_changes(_document(args.file))
111
+
112
+
113
+ def _cmd_changes_apply(args: argparse.Namespace) -> Any:
114
+ if not args.confirm:
115
+ raise argparse.ArgumentTypeError("industrial changes apply requires --confirm")
116
+ return industrial.apply_changes(
117
+ _document(args.file),
118
+ expected_document_hash=args.document_hash,
119
+ expected_plan_hash=args.plan_hash,
120
+ confirmed=True,
121
+ impact_confirmed=args.confirm_impact,
122
+ )
123
+
124
+
125
+ def _cmd_resource_create(args: argparse.Namespace) -> Any:
126
+ return _plan_operation(
127
+ {
128
+ "op": "create",
129
+ "resourceType": args.resource_type,
130
+ "spec": _json_object(args.spec_json, "resource spec"),
131
+ },
132
+ args.output,
133
+ )
134
+
135
+
136
+ def _cmd_resource_update(args: argparse.Namespace) -> Any:
137
+ return _plan_operation(
138
+ {
139
+ "op": "update",
140
+ "resourceType": args.resource_type,
141
+ "target": {"id": args.resource_id, "versionHash": args.version_hash},
142
+ "set": _json_object(args.set_json, "resource update"),
143
+ },
144
+ args.output,
145
+ )
146
+
147
+
148
+ def _cmd_resource_delete(args: argparse.Namespace) -> Any:
149
+ return _plan_operation(
150
+ {
151
+ "op": "delete",
152
+ "resourceType": args.resource_type,
153
+ "target": {"id": args.resource_id, "versionHash": args.version_hash},
154
+ },
155
+ args.output,
156
+ )
157
+
158
+
159
+ def _cmd_runtime_capabilities(_args: argparse.Namespace) -> Any:
160
+ return industrial.runtime_capabilities()
161
+
162
+
163
+ def _cmd_realtime_config(args: argparse.Namespace) -> Any:
164
+ return industrial.realtime_connection(
165
+ point_ids=args.point_ids,
166
+ tag_codes=args.tag_codes,
167
+ transport=args.transport,
168
+ )
169
+
170
+
171
+ def _cmd_point_current(args: argparse.Namespace) -> Any:
172
+ return industrial.point_current(args.point_id)
173
+
174
+
175
+ def _cmd_point_history(args: argparse.Namespace) -> Any:
176
+ return industrial.point_history(
177
+ args.point_id,
178
+ start_time=args.start_time,
179
+ end_time=args.end_time,
180
+ page=args.page,
181
+ page_size=args.page_size,
182
+ )
183
+
184
+
185
+ def _cmd_point_write(args: argparse.Namespace) -> Any:
186
+ if not args.dry_run and not args.confirm:
187
+ raise argparse.ArgumentTypeError(
188
+ "industrial point write requires --confirm unless --dry-run is used"
189
+ )
190
+ return industrial.write_point(
191
+ args.point_id,
192
+ _json(args.value_json, "point value"),
193
+ reason=args.reason,
194
+ ticket=args.ticket,
195
+ transport=args.transport,
196
+ request_id=args.request_id,
197
+ dry_run=args.dry_run,
198
+ confirmed=args.confirm,
199
+ )
200
+
201
+
202
+ def _cmd_video_stream(args: argparse.Namespace) -> Any:
203
+ return industrial.video_stream_options(args.channel_id)
204
+
205
+
206
+ def _cmd_video_start(args: argparse.Namespace) -> Any:
207
+ if not args.confirm:
208
+ raise argparse.ArgumentTypeError("industrial video preview start requires --confirm")
209
+ return industrial.start_video_preview(
210
+ args.channel_id,
211
+ preferred_protocol=args.protocol,
212
+ preferred_protocol_order=args.protocol_order,
213
+ confirmed=True,
214
+ )
215
+
216
+
217
+ def _cmd_video_stop(args: argparse.Namespace) -> Any:
218
+ if not args.confirm:
219
+ raise argparse.ArgumentTypeError("industrial video preview stop requires --confirm")
220
+ return industrial.stop_video_preview(args.channel_id, confirmed=True)
221
+
222
+
223
+ def add_industrial_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
224
+ parser = subparsers.add_parser(
225
+ "industrial", help="Manage collection and video resources without creating UNS nodes"
226
+ )
227
+ commands = parser.add_subparsers(dest="industrial_command", required=True)
228
+
229
+ protocol = commands.add_parser("protocol-schema", help="Get one collection protocol Schema")
230
+ protocol.add_argument("protocol")
231
+ _set_handler(protocol, _cmd_protocol_schema)
232
+
233
+ search = commands.add_parser("resource-search", help="Search collection or video resources")
234
+ search.add_argument("--resource-type", required=True)
235
+ search.add_argument("--protocol")
236
+ search.add_argument("--keyword")
237
+ search.add_argument("--scope", choices=("project", "enterprise"), default="project")
238
+ search.add_argument("--selector-json")
239
+ search.add_argument("--page", type=int, default=1)
240
+ search.add_argument("--page-size", type=int, default=20)
241
+ _set_handler(search, _cmd_resource_search)
242
+
243
+ resource_get = commands.add_parser("resource-get", help="Get a resource and its version hash")
244
+ resource_get.add_argument("--resource-type", required=True)
245
+ resource_get.add_argument("--resource-id", required=True)
246
+ _set_handler(resource_get, _cmd_resource_get)
247
+
248
+ for name, handler, help_text in (
249
+ ("changes-validate", _cmd_changes_validate, "Validate an IndustrialChangeSet file"),
250
+ ("changes-plan", _cmd_changes_plan, "Create a read-only IndustrialChangeSet plan"),
251
+ ):
252
+ command = commands.add_parser(name, help=help_text)
253
+ command.add_argument("--file", required=True)
254
+ _set_handler(command, handler)
255
+
256
+ apply_command = commands.add_parser("changes-apply", help="Apply a reviewed IndustrialChangeSet")
257
+ apply_command.add_argument("--file", required=True)
258
+ apply_command.add_argument("--document-hash", required=True)
259
+ apply_command.add_argument("--plan-hash", required=True)
260
+ apply_command.add_argument("--confirm", action="store_true")
261
+ apply_command.add_argument("--confirm-impact", action="store_true")
262
+ _set_handler(apply_command, _cmd_changes_apply)
263
+
264
+ create = commands.add_parser("resource-create", help="Generate and plan one resource creation")
265
+ create.add_argument("--resource-type", required=True)
266
+ create.add_argument("--spec-json", required=True)
267
+ create.add_argument("--output")
268
+ _set_handler(create, _cmd_resource_create)
269
+
270
+ update = commands.add_parser("resource-update", help="Generate and plan one resource update")
271
+ update.add_argument("--resource-type", required=True)
272
+ update.add_argument("--resource-id", required=True)
273
+ update.add_argument("--version-hash", required=True)
274
+ update.add_argument("--set-json", required=True)
275
+ update.add_argument("--output")
276
+ _set_handler(update, _cmd_resource_update)
277
+
278
+ delete = commands.add_parser("resource-delete", help="Generate and plan one resource deletion")
279
+ delete.add_argument("--resource-type", required=True)
280
+ delete.add_argument("--resource-id", required=True)
281
+ delete.add_argument("--version-hash", required=True)
282
+ delete.add_argument("--output")
283
+ _set_handler(delete, _cmd_resource_delete)
284
+
285
+ _set_handler(
286
+ commands.add_parser("runtime-capabilities", help="Get subscription and control transports"),
287
+ _cmd_runtime_capabilities,
288
+ )
289
+
290
+ realtime = commands.add_parser(
291
+ "realtime-config", help="Build a platform WebSocket URL and subscribe message"
292
+ )
293
+ realtime.add_argument("--point-id", action="append", dest="point_ids")
294
+ realtime.add_argument("--tag-code", action="append", dest="tag_codes")
295
+ realtime.add_argument("--transport", choices=("auto", "websocket", "mqtt"), default="auto")
296
+ _set_handler(realtime, _cmd_realtime_config)
297
+
298
+ current = commands.add_parser("point-current", help="Read a collection point current value")
299
+ current.add_argument("point_id")
300
+ _set_handler(current, _cmd_point_current)
301
+
302
+ history = commands.add_parser("point-history", help="Read collection point history")
303
+ history.add_argument("point_id")
304
+ history.add_argument("--start-time", required=True)
305
+ history.add_argument("--end-time", required=True)
306
+ history.add_argument("--page", type=int, default=1)
307
+ history.add_argument("--page-size", type=int, default=100)
308
+ _set_handler(history, _cmd_point_history)
309
+
310
+ point_write = commands.add_parser("point-write", help="Control a collection point")
311
+ point_write.add_argument("point_id")
312
+ point_write.add_argument("--value-json", required=True)
313
+ point_write.add_argument("--reason", required=True)
314
+ point_write.add_argument("--ticket", required=True)
315
+ point_write.add_argument("--transport", choices=("auto", "http", "mqtt"), default="auto")
316
+ point_write.add_argument("--request-id")
317
+ point_write.add_argument("--dry-run", action="store_true")
318
+ point_write.add_argument("--confirm", action="store_true")
319
+ _set_handler(point_write, _cmd_point_write)
320
+
321
+ stream = commands.add_parser("video-stream", help="Get video channel playback options")
322
+ stream.add_argument("channel_id")
323
+ _set_handler(stream, _cmd_video_stream)
324
+
325
+ video_start = commands.add_parser("video-start", help="Start a video channel preview")
326
+ video_start.add_argument("channel_id")
327
+ video_start.add_argument("--protocol", default="http-flv")
328
+ video_start.add_argument("--protocol-order", action="append")
329
+ video_start.add_argument("--confirm", action="store_true")
330
+ _set_handler(video_start, _cmd_video_start)
331
+
332
+ video_stop = commands.add_parser("video-stop", help="Stop a video channel preview")
333
+ video_stop.add_argument("channel_id")
334
+ video_stop.add_argument("--confirm", action="store_true")
335
+ _set_handler(video_stop, _cmd_video_stop)