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/__init__.py +7 -0
- halyn/__main__.py +4 -0
- halyn/audit.py +278 -0
- halyn/auth.py +88 -0
- halyn/autonomy.py +262 -0
- halyn/cli.py +208 -0
- halyn/config.py +135 -0
- halyn/consent.py +243 -0
- halyn/control_plane.py +354 -0
- halyn/discovery.py +323 -0
- halyn/drivers/__init__.py +0 -0
- halyn/drivers/browser.py +60 -0
- halyn/drivers/dds.py +156 -0
- halyn/drivers/docker.py +62 -0
- halyn/drivers/http_auto.py +259 -0
- halyn/drivers/mqtt.py +93 -0
- halyn/drivers/opcua.py +77 -0
- halyn/drivers/ros2.py +124 -0
- halyn/drivers/serial.py +226 -0
- halyn/drivers/socket_raw.py +153 -0
- halyn/drivers/ssh.py +131 -0
- halyn/drivers/unitree.py +103 -0
- halyn/drivers/websocket.py +175 -0
- halyn/engine.py +222 -0
- halyn/intent.py +240 -0
- halyn/llm.py +178 -0
- halyn/mcp.py +239 -0
- halyn/memory/__init__.py +0 -0
- halyn/memory/store.py +200 -0
- halyn/nrp_bridge.py +213 -0
- halyn/py.typed +0 -0
- halyn/sanitizer.py +120 -0
- halyn/server.py +292 -0
- halyn/types.py +116 -0
- halyn/watchdog.py +252 -0
- halyn-0.2.0.dist-info/METADATA +246 -0
- halyn-0.2.0.dist-info/RECORD +41 -0
- halyn-0.2.0.dist-info/WHEEL +5 -0
- halyn-0.2.0.dist-info/entry_points.txt +2 -0
- halyn-0.2.0.dist-info/licenses/LICENSE +15 -0
- halyn-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
|
halyn/drivers/serial.py
ADDED
|
@@ -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
|