halyn 0.2.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.
halyn/drivers/opcua.py ADDED
@@ -0,0 +1,77 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ OPC-UA Driver — Industrial PLCs, SCADA systems.
5
+
6
+ Connects to Siemens, ABB, Rockwell, Schneider, any OPC-UA server.
7
+ The language of factories.
8
+ """
9
+ from __future__ import annotations
10
+ from typing import Any
11
+ from nrp import NRPDriver, ShieldRule, ShieldType
12
+
13
+ class OPCUADriver(NRPDriver):
14
+ def __init__(self, endpoint: str = "opc.tcp://localhost:4840",
15
+ node_ids: list[str] | None = None) -> None:
16
+ self.endpoint = endpoint
17
+ self.node_ids = node_ids or []
18
+ self._client: Any = None
19
+
20
+ @property
21
+ def kind(self) -> str: return "opcua"
22
+
23
+ @property
24
+ def capabilities(self) -> list[str]:
25
+ return ["read_node", "write_node", "browse", "subscribe"]
26
+
27
+ async def connect(self) -> bool:
28
+ try:
29
+ from asyncua import Client
30
+ self._client = Client(self.endpoint)
31
+ await self._client.connect()
32
+ return True
33
+ except Exception:
34
+ return False
35
+
36
+ async def disconnect(self) -> None:
37
+ if self._client:
38
+ await self._client.disconnect()
39
+
40
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
41
+ if not self._client:
42
+ return {"error": "not connected"}
43
+ ids = channels or self.node_ids
44
+ values: dict[str, Any] = {}
45
+ for node_id in ids:
46
+ try:
47
+ node = self._client.get_node(node_id)
48
+ val = await node.read_value()
49
+ values[node_id] = val
50
+ except Exception as e:
51
+ values[node_id] = f"error: {e}"
52
+ return values
53
+
54
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
55
+ if not self._client:
56
+ raise RuntimeError("Not connected to OPC-UA server")
57
+ if command == "write_node":
58
+ node = self._client.get_node(args["node_id"])
59
+ await node.write_value(args["value"])
60
+ return {"written": args["node_id"], "value": args["value"]}
61
+ if command == "browse":
62
+ node = self._client.get_node(args.get("node_id", "i=84"))
63
+ children = await node.get_children()
64
+ return [{"node_id": str(c.nodeid), "name": (await c.read_browse_name()).Name}
65
+ for c in children[:50]]
66
+ raise ValueError(f"Unknown opcua command: {command}")
67
+
68
+ def shield_rules(self) -> list[ShieldRule]:
69
+ return [
70
+ ShieldRule("read_only_default", ShieldType.PATTERN, "write*",
71
+ description="Write operations need explicit allow"),
72
+ ShieldRule("confirm_write", ShieldType.CONFIRM, "write_node",
73
+ description="Confirm before writing to PLC"),
74
+ ShieldRule("max_write_rate", ShieldType.LIMIT, 10, "writes/s",
75
+ description="Rate limit writes"),
76
+ ]
77
+
halyn/drivers/ros2.py ADDED
@@ -0,0 +1,124 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ ROS2 Driver — Any robot running ROS2.
5
+
6
+ Bridges NRP to the Robot Operating System 2 ecosystem.
7
+ Wraps ROS2 topics (observe) and services/actions (act).
8
+ Works with: Universal Robots, Unitree, TurtleBot, any ROS2 robot.
9
+
10
+ Requires: rclpy (ROS2 Python client library)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from nrp import NRPDriver, ShieldRule, ShieldType
18
+
19
+
20
+ class ROS2Driver(NRPDriver):
21
+ """Control any ROS2 robot through NRP."""
22
+
23
+ def __init__(self, node_name: str = "nrp_bridge",
24
+ observe_topics: dict[str, str] | None = None,
25
+ action_services: dict[str, str] | None = None) -> None:
26
+ self.node_name = node_name
27
+ self.observe_topics = observe_topics or {
28
+ "joint_states": "/joint_states",
29
+ "odom": "/odom",
30
+ "battery": "/battery_state",
31
+ "camera": "/camera/image_raw",
32
+ }
33
+ self.action_services = action_services or {
34
+ "move": "/move_base",
35
+ "gripper": "/gripper_command",
36
+ }
37
+ self._ros_node: Any = None
38
+ self._latest: dict[str, Any] = {}
39
+
40
+ @property
41
+ def kind(self) -> str:
42
+ return "ros2"
43
+
44
+ @property
45
+ def capabilities(self) -> list[str]:
46
+ return list(self.action_services.keys()) + ["emergency_stop", "get_tf"]
47
+
48
+ async def connect(self) -> bool:
49
+ try:
50
+ import rclpy
51
+ from rclpy.node import Node
52
+ if not rclpy.ok():
53
+ rclpy.init()
54
+ self._ros_node = Node(self.node_name)
55
+ # Subscribe to observation topics
56
+ for name, topic in self.observe_topics.items():
57
+ self._create_subscription(name, topic)
58
+ return True
59
+ except ImportError:
60
+ # ROS2 not installed — degrade gracefully
61
+ return False
62
+ except Exception:
63
+ return False
64
+
65
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
66
+ if self._ros_node:
67
+ import rclpy
68
+ rclpy.spin_once(self._ros_node, timeout_sec=0.1)
69
+ if channels:
70
+ return {ch: self._latest.get(ch) for ch in channels}
71
+ return dict(self._latest)
72
+
73
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
74
+ if command == "emergency_stop":
75
+ return await self._emergency_stop()
76
+ service = self.action_services.get(command)
77
+ if not service:
78
+ raise ValueError(f"Unknown command: {command}. Available: {self.capabilities}")
79
+ # Publish to action topic or call service
80
+ return await self._call_service(service, args)
81
+
82
+ def shield_rules(self) -> list[ShieldRule]:
83
+ return [
84
+ ShieldRule("max_velocity", ShieldType.LIMIT, 1.5, unit="m/s",
85
+ description="Maximum linear velocity"),
86
+ ShieldRule("max_angular", ShieldType.LIMIT, 1.0, unit="rad/s",
87
+ description="Maximum angular velocity"),
88
+ ShieldRule("workspace", ShieldType.ZONE,
89
+ {"x": [-2, 2], "y": [-2, 2], "z": [0, 2]}, unit="m",
90
+ description="Allowed workspace bounds"),
91
+ ShieldRule("emergency_stop", ShieldType.COMMAND, True,
92
+ description="Always allowed — immediate stop"),
93
+ ShieldRule("min_battery", ShieldType.THRESHOLD, 10, unit="percent",
94
+ description="Refuse commands below 10% battery"),
95
+ ]
96
+
97
+ async def _emergency_stop(self) -> dict[str, Any]:
98
+ """Immediate stop — highest priority, bypasses all queues."""
99
+ if self._ros_node:
100
+ # Publish zero velocity to cmd_vel
101
+ try:
102
+ from geometry_msgs.msg import Twist
103
+ pub = self._ros_node.create_publisher(Twist, "/cmd_vel", 10)
104
+ pub.publish(Twist()) # All zeros = stop
105
+ return {"stopped": True}
106
+ except Exception as e:
107
+ return {"stopped": False, "error": str(e)}
108
+ return {"stopped": False, "error": "No ROS2 node"}
109
+
110
+ async def _call_service(self, service: str, args: dict[str, Any]) -> Any:
111
+ """Call a ROS2 service. Override for specific robot types."""
112
+ # Base implementation — subclass for specific robots
113
+ return {"service": service, "args": args, "status": "not_implemented"}
114
+
115
+ def _create_subscription(self, name: str, topic: str) -> None:
116
+ """Create a ROS2 subscription. Stores latest message."""
117
+ try:
118
+ from std_msgs.msg import String
119
+ def callback(msg: Any) -> None:
120
+ self._latest[name] = str(msg)
121
+ self._ros_node.create_subscription(String, topic, callback, 10)
122
+ except Exception:
123
+ pass
124
+
@@ -0,0 +1,226 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Serial meta-driver — UART, RS-485, Modbus RTU/TCP.
5
+
6
+ Covers legacy industrial hardware, embedded controllers,
7
+ PLCs, energy meters, and any RS-232/485 device.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import struct
14
+ from typing import Any
15
+
16
+ from nrp import NRPDriver, NRPManifest, NRPId, ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType
17
+
18
+ log = logging.getLogger("halyn.drivers.serial")
19
+
20
+ try:
21
+ import serial
22
+ HAS_SERIAL = True
23
+ except ImportError:
24
+ HAS_SERIAL = False
25
+
26
+ try:
27
+ from pymodbus.client import ModbusTcpClient, ModbusSerialClient
28
+ HAS_MODBUS = True
29
+ except ImportError:
30
+ HAS_MODBUS = False
31
+
32
+
33
+ class SerialDriver(NRPDriver):
34
+ """
35
+ Serial port driver with Modbus support.
36
+
37
+ Modes:
38
+ - raw: direct UART read/write
39
+ - modbus_rtu: Modbus over RS-485 serial
40
+ - modbus_tcp: Modbus over TCP/IP
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ port: str = "/dev/ttyUSB0",
46
+ baudrate: int = 9600,
47
+ mode: str = "raw",
48
+ modbus_host: str = "",
49
+ modbus_port: int = 502,
50
+ unit_id: int = 1,
51
+ registers: dict[str, dict[str, Any]] | None = None,
52
+ ) -> None:
53
+ super().__init__()
54
+ self.port = port
55
+ self.baudrate = baudrate
56
+ self.mode = mode
57
+ self.modbus_host = modbus_host
58
+ self.modbus_port = modbus_port
59
+ self.unit_id = unit_id
60
+ self.registers = registers or {}
61
+ self._serial = None
62
+ self._modbus = None
63
+
64
+ def manifest(self) -> NRPManifest:
65
+ channels = []
66
+ actions = []
67
+
68
+ if self.mode == "raw":
69
+ channels.append(ChannelSpec("buffer", "string", description="Raw serial buffer"))
70
+ actions.append(ActionSpec("write", {"data": "string — bytes to send"}, "Write to serial port"))
71
+ actions.append(ActionSpec("query", {"data": "string", "timeout": "float"}, "Write then read response"))
72
+ else:
73
+ for name, reg in self.registers.items():
74
+ rtype = reg.get("type", "int")
75
+ unit = reg.get("unit", "")
76
+ channels.append(ChannelSpec(name, rtype, unit=unit, description=reg.get("desc", "")))
77
+ if reg.get("writable"):
78
+ actions.append(ActionSpec(
79
+ f"write_{name}",
80
+ {"value": f"{rtype} — target value"},
81
+ f"Write {name} register",
82
+ dangerous=reg.get("dangerous", False),
83
+ ))
84
+
85
+ actions.append(ActionSpec("reconnect", {}, "Reset connection"))
86
+
87
+ return NRPManifest(
88
+ nrp_id=self._nrp_id or NRPId.create("local", "serial", self.port.split("/")[-1]),
89
+ manufacturer="Serial",
90
+ model=f"{self.mode} @ {self.port if self.mode == 'raw' else self.modbus_host}",
91
+ firmware=f"baud={self.baudrate}" if self.mode == "raw" else f"unit={self.unit_id}",
92
+ observe=channels,
93
+ act=actions,
94
+ shield=[ShieldSpec("rate", "limit", 10, "req/s", "Max poll rate")],
95
+ )
96
+
97
+ async def connect(self) -> bool:
98
+ if self.mode == "raw":
99
+ if not HAS_SERIAL:
100
+ log.warning("serial: pyserial not installed")
101
+ return False
102
+ try:
103
+ self._serial = serial.Serial(self.port, self.baudrate, timeout=1)
104
+ return self._serial.is_open
105
+ except Exception as e:
106
+ log.error("serial.connect_failed port=%s error=%s", self.port, e)
107
+ return False
108
+
109
+ elif self.mode in ("modbus_rtu", "modbus_tcp"):
110
+ if not HAS_MODBUS:
111
+ log.warning("serial: pymodbus not installed")
112
+ return False
113
+ try:
114
+ if self.mode == "modbus_tcp":
115
+ self._modbus = ModbusTcpClient(self.modbus_host, port=self.modbus_port)
116
+ else:
117
+ self._modbus = ModbusSerialClient(self.port, baudrate=self.baudrate)
118
+ return self._modbus.connect()
119
+ except Exception as e:
120
+ log.error("modbus.connect_failed error=%s", e)
121
+ return False
122
+ return False
123
+
124
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
125
+ if self.mode == "raw":
126
+ return self._observe_raw()
127
+ return self._observe_modbus(channels)
128
+
129
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
130
+ if command == "reconnect":
131
+ await self.disconnect()
132
+ return {"reconnected": await self.connect()}
133
+
134
+ if self.mode == "raw":
135
+ return self._act_raw(command, args)
136
+ return self._act_modbus(command, args)
137
+
138
+ def shield_rules(self) -> list[ShieldRule]:
139
+ return [ShieldRule("rate", ShieldType.LIMIT, 10)]
140
+
141
+ async def disconnect(self) -> None:
142
+ if self._serial and self._serial.is_open:
143
+ self._serial.close()
144
+ if self._modbus:
145
+ self._modbus.close()
146
+
147
+ def _observe_raw(self) -> dict[str, Any]:
148
+ if not self._serial or not self._serial.is_open:
149
+ return {"buffer": "", "error": "not connected"}
150
+ waiting = self._serial.in_waiting
151
+ data = self._serial.read(min(waiting, 4096)) if waiting else b""
152
+ return {"buffer": data.hex() if data else "", "bytes_waiting": waiting}
153
+
154
+ def _observe_modbus(self, channels: list[str] | None) -> dict[str, Any]:
155
+ if not self._modbus:
156
+ return {"error": "not connected"}
157
+ state: dict[str, Any] = {}
158
+ targets = channels or list(self.registers.keys())
159
+ for name in targets:
160
+ reg = self.registers.get(name)
161
+ if not reg:
162
+ continue
163
+ try:
164
+ addr = reg["address"]
165
+ count = reg.get("count", 1)
166
+ fc = reg.get("function", "holding")
167
+ if fc == "holding":
168
+ result = self._modbus.read_holding_registers(addr, count, slave=self.unit_id)
169
+ elif fc == "input":
170
+ result = self._modbus.read_input_registers(addr, count, slave=self.unit_id)
171
+ elif fc == "coil":
172
+ result = self._modbus.read_coils(addr, count, slave=self.unit_id)
173
+ else:
174
+ continue
175
+
176
+ if hasattr(result, "registers"):
177
+ raw = result.registers
178
+ scale = reg.get("scale", 1.0)
179
+ if count == 1:
180
+ state[name] = raw[0] * scale
181
+ elif reg.get("type") == "float" and count == 2:
182
+ state[name] = struct.unpack(">f", struct.pack(">HH", *raw))[0]
183
+ else:
184
+ state[name] = [v * scale for v in raw]
185
+ elif hasattr(result, "bits"):
186
+ state[name] = result.bits[:count]
187
+ except Exception as e:
188
+ state[name] = f"error: {e}"
189
+ return state
190
+
191
+ def _act_raw(self, command: str, args: dict[str, Any]) -> Any:
192
+ if not self._serial or not self._serial.is_open:
193
+ return {"error": "not connected"}
194
+ if command == "write":
195
+ data = bytes.fromhex(args.get("data", ""))
196
+ self._serial.write(data)
197
+ return {"written": len(data)}
198
+ if command == "query":
199
+ data = bytes.fromhex(args.get("data", ""))
200
+ self._serial.write(data)
201
+ timeout = float(args.get("timeout", 1.0))
202
+ self._serial.timeout = timeout
203
+ response = self._serial.read(4096)
204
+ return {"response": response.hex(), "length": len(response)}
205
+ return {"error": f"unknown command: {command}"}
206
+
207
+ def _act_modbus(self, command: str, args: dict[str, Any]) -> Any:
208
+ if not self._modbus:
209
+ return {"error": "not connected"}
210
+ for name, reg in self.registers.items():
211
+ if command == f"write_{name}" and reg.get("writable"):
212
+ value = args.get("value")
213
+ addr = reg["address"]
214
+ try:
215
+ if reg.get("type") == "float":
216
+ packed = struct.pack(">f", float(value))
217
+ regs = struct.unpack(">HH", packed)
218
+ self._modbus.write_registers(addr, list(regs), slave=self.unit_id)
219
+ elif reg.get("function") == "coil":
220
+ self._modbus.write_coil(addr, bool(value), slave=self.unit_id)
221
+ else:
222
+ self._modbus.write_register(addr, int(float(value)), slave=self.unit_id)
223
+ return {"written": name, "value": value}
224
+ except Exception as e:
225
+ return {"error": str(e)}
226
+ return {"error": f"unknown command: {command}"}
@@ -0,0 +1,153 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Raw socket meta-driver — TCP and UDP.
5
+
6
+ Covers: custom protocols, proprietary hardware, game servers,
7
+ legacy systems, network testing, raw telemetry streams.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import socket
15
+ import time
16
+ from typing import Any
17
+
18
+ from nrp import (
19
+ NRPDriver, NRPManifest, NRPId,
20
+ ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType,
21
+ )
22
+
23
+ log = logging.getLogger("halyn.drivers.socket_raw")
24
+
25
+
26
+ class SocketDriver(NRPDriver):
27
+ """
28
+ Raw TCP/UDP socket driver.
29
+
30
+ Maintains a persistent connection (TCP) or stateless
31
+ send/recv (UDP). Data is exchanged as hex-encoded bytes.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ host: str,
37
+ port: int,
38
+ protocol: str = "tcp",
39
+ buffer_size: int = 4096,
40
+ timeout: float = 5.0,
41
+ ) -> None:
42
+ super().__init__()
43
+ self.host = host
44
+ self.port = port
45
+ self.protocol = protocol.lower()
46
+ self.buffer_size = buffer_size
47
+ self.timeout = timeout
48
+ self._socket: socket.socket | None = None
49
+ self._rx_bytes = 0
50
+ self._tx_bytes = 0
51
+ self._connected_at = 0.0
52
+
53
+ def manifest(self) -> NRPManifest:
54
+ return NRPManifest(
55
+ nrp_id=self._nrp_id or NRPId.create(
56
+ "local", "socket", f"{self.host}-{self.port}".replace(".", "-")
57
+ ),
58
+ manufacturer="Socket",
59
+ model=f"{self.protocol.upper()} {self.host}:{self.port}",
60
+ observe=[
61
+ ChannelSpec("connected", "bool"),
62
+ ChannelSpec("rx_bytes", "int"),
63
+ ChannelSpec("tx_bytes", "int"),
64
+ ChannelSpec("uptime", "float", unit="seconds"),
65
+ ChannelSpec("buffer", "string", description="Last received data (hex)"),
66
+ ],
67
+ act=[
68
+ ActionSpec("send", {"data": "string — hex-encoded bytes"}, "Send raw bytes"),
69
+ ActionSpec("query", {"data": "string", "timeout": "float"}, "Send and receive"),
70
+ ActionSpec("reconnect", {}, "Reset connection"),
71
+ ],
72
+ shield=[ShieldSpec("max_send", "limit", 65535, "bytes")],
73
+ )
74
+
75
+ async def connect(self) -> bool:
76
+ try:
77
+ sock_type = socket.SOCK_STREAM if self.protocol == "tcp" else socket.SOCK_DGRAM
78
+ self._socket = socket.socket(socket.AF_INET, sock_type)
79
+ self._socket.settimeout(self.timeout)
80
+ if self.protocol == "tcp":
81
+ self._socket.connect((self.host, self.port))
82
+ self._connected_at = time.time()
83
+ return True
84
+ except Exception as e:
85
+ log.error("socket.connect_failed %s:%d error=%s", self.host, self.port, e)
86
+ return False
87
+
88
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
89
+ connected = self._socket is not None
90
+ state: dict[str, Any] = {
91
+ "connected": connected,
92
+ "rx_bytes": self._rx_bytes,
93
+ "tx_bytes": self._tx_bytes,
94
+ "uptime": time.time() - self._connected_at if self._connected_at else 0,
95
+ }
96
+ if connected and self.protocol == "tcp":
97
+ try:
98
+ self._socket.setblocking(False)
99
+ data = self._socket.recv(self.buffer_size)
100
+ state["buffer"] = data.hex()
101
+ self._rx_bytes += len(data)
102
+ except (BlockingIOError, socket.error):
103
+ state["buffer"] = ""
104
+ finally:
105
+ self._socket.setblocking(True)
106
+ self._socket.settimeout(self.timeout)
107
+ return state
108
+
109
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
110
+ if command == "reconnect":
111
+ await self.disconnect()
112
+ return {"reconnected": await self.connect()}
113
+
114
+ if not self._socket:
115
+ return {"error": "not connected"}
116
+
117
+ if command == "send":
118
+ data = bytes.fromhex(args.get("data", ""))
119
+ if self.protocol == "tcp":
120
+ self._socket.sendall(data)
121
+ else:
122
+ self._socket.sendto(data, (self.host, self.port))
123
+ self._tx_bytes += len(data)
124
+ return {"sent": len(data)}
125
+
126
+ if command == "query":
127
+ data = bytes.fromhex(args.get("data", ""))
128
+ timeout = float(args.get("timeout", self.timeout))
129
+ if self.protocol == "tcp":
130
+ self._socket.sendall(data)
131
+ else:
132
+ self._socket.sendto(data, (self.host, self.port))
133
+ self._tx_bytes += len(data)
134
+ self._socket.settimeout(timeout)
135
+ try:
136
+ resp = self._socket.recv(self.buffer_size)
137
+ self._rx_bytes += len(resp)
138
+ return {"response": resp.hex(), "length": len(resp)}
139
+ except socket.timeout:
140
+ return {"response": "", "timeout": True}
141
+
142
+ return {"error": f"unknown: {command}"}
143
+
144
+ def shield_rules(self) -> list[ShieldRule]:
145
+ return [ShieldRule("max_send", ShieldType.LIMIT, 65535)]
146
+
147
+ async def disconnect(self) -> None:
148
+ if self._socket:
149
+ try:
150
+ self._socket.close()
151
+ except Exception:
152
+ pass
153
+ self._socket = None