licos-platform-cli 0.4.0__tar.gz → 0.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-platform-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: LICOS platform CLI
5
5
  Requires-Python: >=3.10
6
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.4.0"
7
+ version = "0.4.1"
8
8
  description = "LICOS platform CLI"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-platform-sdk>=0.5.0",
11
+ "licos-platform-sdk>=0.5.0",
12
12
  ]
13
13
 
14
14
  [project.scripts]
@@ -31,92 +31,105 @@ STORAGE_HELP = {
31
31
  "node": "import { storage } from '@kmlckj/licos-platform-sdk';\nconst file = await storage.uploadFile('/workspace/projects/report.pdf');\nconst link = await storage.shareUrl(file.id);",
32
32
  },
33
33
  "commands": {
34
- "summary": {
35
- "description": "Return project storage summary.",
36
- "parameters": [],
37
- "example": "licos-platform storage summary",
38
- },
34
+ "summary": {
35
+ "description": "Return project storage summary.",
36
+ "parameters": [],
37
+ "returns": "Storage summary object: IDs/scope strings, usedSizeBytes integer, fileCount/folderCount integers, serviceEnabled boolean.",
38
+ "example": "licos-platform storage summary",
39
+ },
39
40
  "folders": {
40
- "description": "List all folders in the current project storage scope.",
41
- "parameters": [],
42
- "example": "licos-platform storage folders",
43
- },
41
+ "description": "List all folders in the current project storage scope.",
42
+ "parameters": [],
43
+ "returns": "Array of folder objects. Folder IDs are durable operation identifiers; parentId is null for root.",
44
+ "example": "licos-platform storage folders",
45
+ },
44
46
  "entries": {
45
47
  "description": "List folders and files under a folder; omit folder-id for root.",
46
- "parameters": [{"name": "--folder-id", "required": False, "description": "Folder ID. Omit for root."}],
47
- "example": "licos-platform storage entries --folder-id folder-id",
48
- },
48
+ "parameters": [{"name": "--folder-id", "type": "string", "required": False, "description": "Folder ID. Omit for root."}],
49
+ "returns": "Directory entries object with currentFolderId, currentFolderName, currentFolderPath, breadcrumbs, folders, and files.",
50
+ "example": "licos-platform storage entries --folder-id folder-id",
51
+ },
49
52
  "files": {
50
- "description": "List all files in the current project storage scope.",
51
- "parameters": [],
52
- "example": "licos-platform storage files",
53
- },
53
+ "description": "List all files in the current project storage scope.",
54
+ "parameters": [],
55
+ "returns": "Array of file objects. downloadUrl is temporary; use id/storageKey as durable identity.",
56
+ "example": "licos-platform storage files",
57
+ },
54
58
  "create-folder": {
55
59
  "description": "Create a folder under root or a parent folder.",
56
60
  "parameters": [
57
- {"name": "folder_name", "required": True, "description": "New folder name."},
58
- {"name": "--parent-id", "required": False, "description": "Parent folder ID. Omit for root."},
59
- ],
61
+ {"name": "folder_name", "type": "string", "required": True, "description": "Non-empty new folder name."},
62
+ {"name": "--parent-id", "type": "string", "required": False, "description": "Parent folder ID. Omit for root."},
63
+ ],
64
+ "returns": "Created folder object.",
60
65
  "example": "licos-platform storage create-folder reports --parent-id folder-id",
61
66
  },
62
67
  "rename-folder": {
63
68
  "description": "Rename a folder by folder ID.",
64
69
  "parameters": [
65
- {"name": "folder_id", "required": True, "description": "Folder ID."},
66
- {"name": "folder_name", "required": True, "description": "New folder name."},
67
- ],
70
+ {"name": "folder_id", "type": "string", "required": True, "description": "Exact folder ID."},
71
+ {"name": "folder_name", "type": "string", "required": True, "description": "Non-empty new folder name."},
72
+ ],
73
+ "returns": "Updated folder object.",
68
74
  "example": "licos-platform storage rename-folder folder-id reports",
69
75
  },
70
76
  "delete-folder": {
71
77
  "description": "Delete a folder by folder ID.",
72
- "parameters": [{"name": "folder_id", "required": True, "description": "Folder ID."}],
78
+ "parameters": [{"name": "folder_id", "type": "string", "required": True, "description": "Exact folder ID."}],
79
+ "returns": "{\"success\": true} on success.",
73
80
  "example": "licos-platform storage delete-folder folder-id",
74
81
  },
75
82
  "upload-file": {
76
- "description": "Upload a local file up to 20 MiB, bytes are sent as multipart form data, and the returned file object must be used.",
77
- "parameters": [
78
- {"name": "file", "required": True, "description": "Local file path."},
79
- {"name": "--folder-id", "required": False, "description": "Target folder ID. Omit for root."},
80
- {"name": "--file-name", "required": False, "description": "Override uploaded display file name."},
81
- {"name": "--content-type", "required": False, "description": "MIME type, for example application/pdf."},
82
- ],
83
+ "description": "Upload a local file up to 100 MiB. Files over 8 MiB automatically use multipart upload; always use the returned file object.",
84
+ "parameters": [
85
+ {"name": "file", "type": "path", "required": True, "description": "Readable local file path; maximum size 100 MiB."},
86
+ {"name": "--folder-id", "type": "string", "required": False, "description": "Target folder ID. Omit for root."},
87
+ {"name": "--file-name", "type": "string", "required": False, "description": "Override uploaded display file name."},
88
+ {"name": "--content-type", "type": "MIME string", "required": False, "description": "MIME type, for example application/pdf."},
89
+ ],
90
+ "returns": "Uploaded file object with durable id/storageKey metadata and a temporary downloadUrl.",
83
91
  "example": "licos-platform storage upload-file ./report.pdf --folder-id folder-id --file-name report.pdf --content-type application/pdf",
84
92
  },
85
93
  "rename-file": {
86
94
  "description": "Rename a file by file ID.",
87
95
  "parameters": [
88
- {"name": "file_id", "required": True, "description": "File ID."},
89
- {"name": "file_name", "required": True, "description": "New file name."},
90
- ],
96
+ {"name": "file_id", "type": "string", "required": True, "description": "Exact file ID."},
97
+ {"name": "file_name", "type": "string", "required": True, "description": "Non-empty new file name."},
98
+ ],
99
+ "returns": "Updated file object.",
91
100
  "example": "licos-platform storage rename-file file-id final.pdf",
92
101
  },
93
102
  "move-file": {
94
103
  "description": "Move a file to a folder; omit folder-id to move to root.",
95
104
  "parameters": [
96
- {"name": "file_id", "required": True, "description": "File ID."},
97
- {"name": "--folder-id", "required": False, "description": "Target folder ID. Omit for root."},
98
- ],
105
+ {"name": "file_id", "type": "string", "required": True, "description": "Exact file ID."},
106
+ {"name": "--folder-id", "type": "string", "required": False, "description": "Target folder ID. Omit to move to root."},
107
+ ],
108
+ "returns": "Updated file object.",
99
109
  "example": "licos-platform storage move-file file-id --folder-id folder-id",
100
110
  },
101
111
  "share-url": {
102
112
  "description": "Generate a temporary share URL for a file.",
103
113
  "parameters": [
104
- {"name": "file_id", "required": True, "description": "File ID."},
105
- {"name": "--expiry-seconds", "required": False, "description": "URL lifetime in seconds."},
106
- ],
114
+ {"name": "file_id", "type": "string", "required": True, "description": "Exact file ID."},
115
+ {"name": "--expiry-seconds", "type": "integer", "required": False, "description": "Positive URL lifetime in seconds. Omit for the platform default of 7 days."},
116
+ ],
117
+ "returns": "Object with temporary url string and expiresAt datetime string.",
107
118
  "example": "licos-platform storage share-url file-id --expiry-seconds 3600",
108
119
  },
109
120
  "object-exists": {
110
121
  "description": "Check whether an object exists by file ID or object key.",
111
122
  "parameters": [
112
- {"name": "--file-id", "required": False, "description": "File ID."},
113
- {"name": "--object-key", "required": False, "description": "Storage object key."},
114
- ],
123
+ {"name": "--file-id", "type": "string", "required": False, "description": "File ID. Takes precedence when both selectors are provided."},
124
+ {"name": "--object-key", "type": "string", "required": False, "description": "Storage object key. At least one selector is required."},
125
+ ],
126
+ "returns": "Object with fileId, bucketName, objectKey, and boolean exists.",
115
127
  "example": "licos-platform storage object-exists --file-id file-id",
116
128
  },
117
129
  "delete-file": {
118
130
  "description": "Delete a file by file ID.",
119
- "parameters": [{"name": "file_id", "required": True, "description": "File ID."}],
131
+ "parameters": [{"name": "file_id", "type": "string", "required": True, "description": "Exact file ID."}],
132
+ "returns": "{\"success\": true} on success.",
120
133
  "example": "licos-platform storage delete-file file-id",
121
134
  },
122
135
  },
@@ -767,116 +780,116 @@ UNS_HELP = {
767
780
  }
768
781
 
769
782
 
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 = {
783
+ INDUSTRIAL_HELP = {
784
+ "topic": "industrial",
785
+ "description": "Project-scoped direct collection and video integration without creating UNS nodes.",
786
+ "runtime": RUNTIME_ENV,
787
+ "rules": [
788
+ "Use only when the user explicitly requests direct collector access or explicitly says not to create UNS.",
789
+ "CLI and SDK call platform APIs; never request or expose IOTDATA API keys or MQTT credentials.",
790
+ "Search before create. Update and delete require the versionHash returned by resource-get/search.",
791
+ "Apply requires reviewed documentHash and planHash. Delete also requires --confirm-impact.",
792
+ "Realtime defaults to WebSocket; MQTT must be selected explicitly. Point control defaults to HTTP; MQTT must be selected explicitly.",
793
+ "Transport failures do not silently switch to another transport.",
794
+ ],
795
+ "resourceTypes": [
796
+ "edgeAgent (read-only)",
797
+ "connection",
798
+ "collectionTask",
799
+ "collectionPoint",
800
+ "videoEdge",
801
+ "videoGateway",
802
+ "videoDevice",
803
+ "videoChannel",
804
+ ],
805
+ "commands": {
806
+ "protocol-schema": {
807
+ "description": "Get required and default fields for one collection protocol.",
808
+ "parameters": [{"name": "protocol", "required": True, "description": "Protocol code."}],
809
+ "example": "licos-platform industrial protocol-schema modbus-tcp",
810
+ },
811
+ "resource-search": {
812
+ "description": "Search project-managed or enterprise collection/video resources.",
813
+ "parameters": [
814
+ {"name": "--resource-type", "required": True, "description": "Allowlisted resource type."},
815
+ {"name": "--scope", "required": False, "description": "project or enterprise."},
816
+ {"name": "--protocol", "required": False, "description": "Collection protocol filter."},
817
+ {"name": "--keyword", "required": False, "description": "Name/code/address keyword."},
818
+ {"name": "--selector-json", "required": False, "description": "Exact field selector object."},
819
+ ],
820
+ "example": "licos-platform industrial resource-search --resource-type collectionPoint --scope enterprise --keyword TEMP",
821
+ },
822
+ "resource-get": {
823
+ "description": "Get one resource, writable state, and concurrency versionHash.",
824
+ "parameters": [
825
+ {"name": "--resource-type", "required": True, "description": "Allowlisted resource type."},
826
+ {"name": "--resource-id", "required": True, "description": "Resource ID."},
827
+ ],
828
+ "example": "licos-platform industrial resource-get --resource-type collectionPoint --resource-id point-id",
829
+ },
830
+ "changes-validate / changes-plan / changes-apply": {
831
+ "description": "Validate, review, and apply an IndustrialChangeSet JSON file.",
832
+ "parameters": [
833
+ {"name": "--file", "required": True, "description": "IndustrialChangeSet JSON file."},
834
+ {"name": "--document-hash / --plan-hash", "required": True, "description": "Required only by apply."},
835
+ {"name": "--confirm", "required": True, "description": "Required only by apply."},
836
+ {"name": "--confirm-impact", "required": False, "description": "Required when the plan contains delete impact."},
837
+ ],
838
+ "example": "licos-platform industrial changes-plan --file ./industrial.change.json",
839
+ },
840
+ "resource-create / resource-update / resource-delete": {
841
+ "description": "Generate and plan one operation; these commands do not apply writes.",
842
+ "parameters": [
843
+ {"name": "--resource-type", "required": True, "description": "Allowlisted resource type."},
844
+ {"name": "--spec-json / --set-json", "required": False, "description": "Create or update fields."},
845
+ {"name": "--resource-id / --version-hash", "required": False, "description": "Required by update/delete."},
846
+ {"name": "--output", "required": False, "description": "Write the generated ChangeSet under LICOS_PROJECT_PATH."},
847
+ ],
848
+ "example": "licos-platform industrial resource-update --resource-type collectionPoint --resource-id point-id --version-hash HASH --set-json '{\"name\":\"炉温\"}'",
849
+ },
850
+ "realtime-config": {
851
+ "description": "Build the platform WebSocket URL and subscribe message for application code.",
852
+ "parameters": [
853
+ {"name": "--point-id / --tag-code", "required": True, "description": "At least one selector; repeatable."},
854
+ {"name": "--transport", "required": False, "description": "auto/websocket defaults to WebSocket; mqtt is explicit."},
855
+ ],
856
+ "example": "licos-platform industrial realtime-config --point-id point-id --transport websocket",
857
+ },
858
+ "point-current / point-history": {
859
+ "description": "Read current or historical point values.",
860
+ "parameters": [{"name": "point_id", "required": True, "description": "Collection point ID."}],
861
+ "example": "licos-platform industrial point-current point-id",
862
+ },
863
+ "point-write": {
864
+ "description": "Dry-run or control a writable project-managed point.",
865
+ "parameters": [
866
+ {"name": "point_id", "required": True, "description": "Collection point ID."},
867
+ {"name": "--value-json", "required": True, "description": "JSON scalar/object value."},
868
+ {"name": "--reason / --ticket", "required": True, "description": "Audit reason and ticket."},
869
+ {"name": "--transport", "required": False, "description": "auto/http defaults to HTTP; mqtt is explicit."},
870
+ {"name": "--dry-run / --confirm", "required": True, "description": "One safety mode is required."},
871
+ ],
872
+ "example": "licos-platform industrial point-write point-id --value-json 80 --reason 调试 --ticket T-1 --confirm",
873
+ },
874
+ "video-stream / video-start / video-stop": {
875
+ "description": "Get playback options or explicitly start/stop a project-managed video preview.",
876
+ "parameters": [
877
+ {"name": "channel_id", "required": True, "description": "Video channel ID."},
878
+ {"name": "--protocol", "required": False, "description": "Start defaults to http-flv."},
879
+ {"name": "--confirm", "required": True, "description": "Required by start/stop."},
880
+ ],
881
+ "example": "licos-platform industrial video-start channel-id --protocol http-flv --confirm",
882
+ },
883
+ },
884
+ }
885
+
886
+
887
+ HELP_TOPICS = {
875
888
  "storage": STORAGE_HELP,
876
889
  "database": DATABASE_HELP,
877
890
  "knowledge": KNOWLEDGE_HELP,
878
- "oauth": OAUTH_HELP,
879
- "industrial": INDUSTRIAL_HELP,
891
+ "oauth": OAUTH_HELP,
892
+ "industrial": INDUSTRIAL_HELP,
880
893
  "uns": UNS_HELP,
881
894
  }
882
895
 
@@ -1,335 +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)
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)
@@ -8,8 +8,8 @@ from typing import Any, Sequence
8
8
  from licos_platform_sdk import PlatformSdkError
9
9
 
10
10
  from .database_commands import add_database_parser
11
- from .help_commands import add_help_parser
12
- from .industrial_commands import add_industrial_parser
11
+ from .help_commands import add_help_parser
12
+ from .industrial_commands import add_industrial_parser
13
13
  from .knowledge_commands import add_knowledge_parser
14
14
  from .oauth_commands import add_oauth_parser
15
15
  from .storage_commands import add_storage_parser
@@ -35,9 +35,9 @@ def _build_parser() -> argparse.ArgumentParser:
35
35
  add_storage_parser(subparsers)
36
36
  add_database_parser(subparsers)
37
37
  add_knowledge_parser(subparsers)
38
- add_oauth_parser(subparsers)
39
- add_uns_parser(subparsers)
40
- add_industrial_parser(subparsers)
38
+ add_oauth_parser(subparsers)
39
+ add_uns_parser(subparsers)
40
+ add_industrial_parser(subparsers)
41
41
  return parser
42
42
 
43
43
 
@@ -14,14 +14,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15
15
 
16
16
  from licos_platform_cli import main as cli_main
17
- from licos_platform_cli import (
18
- database_commands,
19
- industrial_commands,
20
- knowledge_commands,
21
- oauth_commands,
22
- storage_commands,
23
- uns_commands,
24
- )
17
+ from licos_platform_cli import (
18
+ database_commands,
19
+ industrial_commands,
20
+ knowledge_commands,
21
+ oauth_commands,
22
+ storage_commands,
23
+ uns_commands,
24
+ )
25
25
  from licos_platform_sdk import ApiError, PlatformSdkError
26
26
 
27
27
 
@@ -53,10 +53,10 @@ class PlatformCliTests(unittest.TestCase):
53
53
  self.assertEqual(code, 0)
54
54
  self.assertEqual(stderr, "")
55
55
  payload = json.loads(stdout)
56
- self.assertEqual(
57
- payload["topics"],
58
- ["database", "industrial", "knowledge", "oauth", "storage", "uns"],
59
- )
56
+ self.assertEqual(
57
+ payload["topics"],
58
+ ["database", "industrial", "knowledge", "oauth", "storage", "uns"],
59
+ )
60
60
  self.assertIn("licos-platform help storage", payload["usage"])
61
61
 
62
62
  def test_structured_help_storage_command_describes_parameters(self) -> None:
@@ -94,7 +94,7 @@ class PlatformCliTests(unittest.TestCase):
94
94
  self.assertIn("search", payload["commands"])
95
95
  self.assertTrue(any("Search all accessible datasets by default" in rule for rule in payload["rules"]))
96
96
 
97
- def test_structured_help_uns_exposes_only_confirmed_apply(self) -> None:
97
+ def test_structured_help_uns_exposes_only_confirmed_apply(self) -> None:
98
98
  code, stdout, stderr = run_cli("help", "uns")
99
99
 
100
100
  self.assertEqual(code, 0)
@@ -102,49 +102,49 @@ class PlatformCliTests(unittest.TestCase):
102
102
  payload = json.loads(stdout)
103
103
  self.assertIn("apply", payload["commands"])
104
104
  parameters = {item["name"] for item in payload["commands"]["apply"]["parameters"]}
105
- self.assertEqual(parameters, {"--file", "--document-hash", "--plan-hash", "--confirm"})
106
-
107
- def test_structured_help_industrial_declares_transport_defaults(self) -> None:
108
- code, stdout, stderr = run_cli("help", "industrial")
109
-
110
- self.assertEqual(code, 0)
111
- self.assertEqual(stderr, "")
112
- payload = json.loads(stdout)
113
- self.assertTrue(any("Realtime defaults to WebSocket" in rule for rule in payload["rules"]))
114
- self.assertTrue(any("Point control defaults to HTTP" in rule for rule in payload["rules"]))
115
-
116
- def test_industrial_point_write_forwards_explicit_mqtt_transport(self) -> None:
117
- with mock.patch.object(
118
- industrial_commands.industrial, "write_point", return_value={"transport": "mqtt"}
119
- ) as write_point:
120
- code, stdout, stderr = run_cli(
121
- "industrial",
122
- "point-write",
123
- "point-1",
124
- "--value-json",
125
- "80",
126
- "--reason",
127
- "test",
128
- "--ticket",
129
- "T-1",
130
- "--transport",
131
- "mqtt",
132
- "--confirm",
133
- )
134
-
135
- self.assertEqual(code, 0)
136
- self.assertEqual(stderr, "")
137
- self.assertEqual(json.loads(stdout), {"transport": "mqtt"})
138
- write_point.assert_called_once_with(
139
- "point-1",
140
- 80,
141
- reason="test",
142
- ticket="T-1",
143
- transport="mqtt",
144
- request_id=None,
145
- dry_run=False,
146
- confirmed=True,
147
- )
105
+ self.assertEqual(parameters, {"--file", "--document-hash", "--plan-hash", "--confirm"})
106
+
107
+ def test_structured_help_industrial_declares_transport_defaults(self) -> None:
108
+ code, stdout, stderr = run_cli("help", "industrial")
109
+
110
+ self.assertEqual(code, 0)
111
+ self.assertEqual(stderr, "")
112
+ payload = json.loads(stdout)
113
+ self.assertTrue(any("Realtime defaults to WebSocket" in rule for rule in payload["rules"]))
114
+ self.assertTrue(any("Point control defaults to HTTP" in rule for rule in payload["rules"]))
115
+
116
+ def test_industrial_point_write_forwards_explicit_mqtt_transport(self) -> None:
117
+ with mock.patch.object(
118
+ industrial_commands.industrial, "write_point", return_value={"transport": "mqtt"}
119
+ ) as write_point:
120
+ code, stdout, stderr = run_cli(
121
+ "industrial",
122
+ "point-write",
123
+ "point-1",
124
+ "--value-json",
125
+ "80",
126
+ "--reason",
127
+ "test",
128
+ "--ticket",
129
+ "T-1",
130
+ "--transport",
131
+ "mqtt",
132
+ "--confirm",
133
+ )
134
+
135
+ self.assertEqual(code, 0)
136
+ self.assertEqual(stderr, "")
137
+ self.assertEqual(json.loads(stdout), {"transport": "mqtt"})
138
+ write_point.assert_called_once_with(
139
+ "point-1",
140
+ 80,
141
+ reason="test",
142
+ ticket="T-1",
143
+ transport="mqtt",
144
+ request_id=None,
145
+ dry_run=False,
146
+ confirmed=True,
147
+ )
148
148
 
149
149
  def test_uns_validate_loads_json_and_calls_sdk(self) -> None:
150
150
  document = {"schemaVersion": "1.0", "resources": {}, "uns": {"nodes": []}}