nrprotocol 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nrprotocol-0.1.0/LICENSE +15 -0
- nrprotocol-0.1.0/PKG-INFO +223 -0
- nrprotocol-0.1.0/README.md +203 -0
- nrprotocol-0.1.0/pyproject.toml +29 -0
- nrprotocol-0.1.0/sdk/python/nrp/__init__.py +27 -0
- nrprotocol-0.1.0/sdk/python/nrp/driver.py +112 -0
- nrprotocol-0.1.0/sdk/python/nrp/events.py +234 -0
- nrprotocol-0.1.0/sdk/python/nrp/identity.py +71 -0
- nrprotocol-0.1.0/sdk/python/nrp/manifest.py +166 -0
- nrprotocol-0.1.0/sdk/python/nrp/py.typed +0 -0
- nrprotocol-0.1.0/sdk/python/nrprotocol.egg-info/PKG-INFO +223 -0
- nrprotocol-0.1.0/sdk/python/nrprotocol.egg-info/SOURCES.txt +13 -0
- nrprotocol-0.1.0/sdk/python/nrprotocol.egg-info/dependency_links.txt +1 -0
- nrprotocol-0.1.0/sdk/python/nrprotocol.egg-info/top_level.txt +1 -0
- nrprotocol-0.1.0/setup.cfg +4 -0
nrprotocol-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elmadani SALKA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files, to deal in the Software
|
|
7
|
+
without restriction, including without limitation the rights to use, copy,
|
|
8
|
+
modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
9
|
+
Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nrprotocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Node Reach Protocol — The universal standard for AI-to-world control.
|
|
5
|
+
Author-email: Elmadani SALKA <Elmadani.SALKA@proton.me>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ElmadaniS/nrp
|
|
8
|
+
Project-URL: Repository, https://github.com/ElmadaniS/nrp
|
|
9
|
+
Project-URL: Issues, https://github.com/ElmadaniS/nrp/issues
|
|
10
|
+
Keywords: ai,robotics,iot,protocol,mcp,nrp,llm,edge
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Classifier: Topic :: System :: Networking
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
<div align="center">
|
|
22
|
+
|
|
23
|
+
<img src="logo.svg" width="80" alt="Halyn">
|
|
24
|
+
|
|
25
|
+
# NRP
|
|
26
|
+
|
|
27
|
+
### Node Reach Protocol
|
|
28
|
+
|
|
29
|
+
**The universal standard for AI-to-world control.**
|
|
30
|
+
|
|
31
|
+
MCP connects LLMs to software.<br>
|
|
32
|
+
**NRP connects LLMs to everything else.**
|
|
33
|
+
|
|
34
|
+
Servers · Robots · Drones · Sensors · Vehicles · APIs · Factories · Smart Homes
|
|
35
|
+
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://python.org)
|
|
38
|
+
[]()
|
|
39
|
+
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## The Problem
|
|
45
|
+
|
|
46
|
+
Every device speaks a different language. SSH for servers. ROS2 for robots. MQTT for sensors. OPC-UA for factories. REST for APIs. Hundreds of protocols. Thousands of SDKs. No standard.
|
|
47
|
+
|
|
48
|
+
MCP standardized software integration. NRP does the same for hardware and physical systems.
|
|
49
|
+
|
|
50
|
+
## The Protocol
|
|
51
|
+
|
|
52
|
+
3 methods. That is the interface.
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
OBSERVE → read state (sensors, metrics, cameras, APIs)
|
|
56
|
+
ACT → change state (commands, movements, writes, calls)
|
|
57
|
+
SHIELD → safety limits (boundaries the AI cannot cross)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every device becomes a **node**. Every node has an address:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
nrp://factory/robot/arm-7
|
|
64
|
+
nrp://farm/sensor/soil-north
|
|
65
|
+
nrp://cloud/api/stripe
|
|
66
|
+
nrp://home/light/kitchen
|
|
67
|
+
nrp://fleet/vehicle/truck-42
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every node **describes itself**. The AI reads the manifest and knows what to do. Zero configuration. Zero documentation.
|
|
71
|
+
|
|
72
|
+
## Write a Driver in 100 Lines
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from nrp import NRPDriver, NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
76
|
+
|
|
77
|
+
class MyRobot(NRPDriver):
|
|
78
|
+
|
|
79
|
+
def manifest(self) -> NRPManifest:
|
|
80
|
+
return NRPManifest(
|
|
81
|
+
nrp_id=self._nrp_id,
|
|
82
|
+
manufacturer="Unitree", model="G1",
|
|
83
|
+
observe=[
|
|
84
|
+
ChannelSpec("joints", "float[]", unit="rad", rate="100Hz"),
|
|
85
|
+
ChannelSpec("battery", "int", unit="percent"),
|
|
86
|
+
ChannelSpec("camera", "image", rate="30Hz"),
|
|
87
|
+
],
|
|
88
|
+
act=[
|
|
89
|
+
ActionSpec("walk", {"speed": "float m/s"}, "Walk forward", dangerous=True),
|
|
90
|
+
ActionSpec("pick", {"target": "string"}, "Pick an object", dangerous=True),
|
|
91
|
+
ActionSpec("stand", {}, "Stand still"),
|
|
92
|
+
],
|
|
93
|
+
shield=[
|
|
94
|
+
ShieldSpec("max_speed", "limit", 1.5, "m/s"),
|
|
95
|
+
ShieldSpec("workspace", "zone", [0, 0, 10, 10], "meters"),
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def observe(self, channels=None):
|
|
100
|
+
return {"joints": self.read_joints(), "battery": self.read_battery()}
|
|
101
|
+
|
|
102
|
+
async def act(self, command, args):
|
|
103
|
+
if command == "walk":
|
|
104
|
+
return self.walk(args["speed"])
|
|
105
|
+
if command == "pick":
|
|
106
|
+
return self.pick(args["target"])
|
|
107
|
+
|
|
108
|
+
def shield_rules(self):
|
|
109
|
+
return [ShieldRule("max_speed", ShieldType.LIMIT, 1.5)]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
## What the AI Sees
|
|
115
|
+
|
|
116
|
+
When a node connects, the AI receives a human-readable description:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Node: nrp://factory/robot/g1-01
|
|
120
|
+
Device: Unitree G1
|
|
121
|
+
Observe (read state):
|
|
122
|
+
joints: float[] (rad) — Joint angles at 100Hz
|
|
123
|
+
battery: int (percent) — Battery level
|
|
124
|
+
camera: image — Camera feed at 30Hz
|
|
125
|
+
Act (commands):
|
|
126
|
+
walk(speed: float m/s) [DANGEROUS] — Walk forward
|
|
127
|
+
pick(target: string) [DANGEROUS] — Pick an object
|
|
128
|
+
stand() — Stand still
|
|
129
|
+
Shield (safety limits):
|
|
130
|
+
max_speed: limit = 1.5 m/s
|
|
131
|
+
workspace: zone = [0, 0, 10, 10] meters
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
## Real-Time Events
|
|
137
|
+
|
|
138
|
+
Nodes push events. The AI reacts without polling.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# The node pushes
|
|
142
|
+
await driver.emit("battery_low", Severity.WARNING, percent=8)
|
|
143
|
+
await driver.emit("collision", Severity.EMERGENCY, force=45.2)
|
|
144
|
+
|
|
145
|
+
# The control plane routes
|
|
146
|
+
bus.subscribe("battery_*", alert_handler)
|
|
147
|
+
bus.subscribe("nrp://factory/*", factory_monitor)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Emergency events bypass all queues** and are processed synchronously.
|
|
151
|
+
|
|
152
|
+
## Specification
|
|
153
|
+
|
|
154
|
+
| Document | Description |
|
|
155
|
+
|----------|-------------|
|
|
156
|
+
| [IDENTITY.md](spec/IDENTITY.md) | Universal addressing: `nrp://scope/kind/name` |
|
|
157
|
+
| [MANIFEST.md](spec/MANIFEST.md) | Self-describing nodes: channels, actions, shields |
|
|
158
|
+
| [EVENTS.md](spec/EVENTS.md) | Real-time push: severity levels, emergency bypass |
|
|
159
|
+
| [NRP_SPEC.md](spec/NRP_SPEC.md) | Protocol overview |
|
|
160
|
+
|
|
161
|
+
## Install
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install nrp
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Examples
|
|
168
|
+
|
|
169
|
+
- [`hello_ssh.py`](examples/hello_ssh.py) — Connect to a server in 60 lines
|
|
170
|
+
- [`multi_node.py`](examples/multi_node.py) — 3 sensors, 1 conversation
|
|
171
|
+
|
|
172
|
+
## Built With NRP
|
|
173
|
+
|
|
174
|
+
| Project | Description |
|
|
175
|
+
|---------|-------------|
|
|
176
|
+
| [Halyn](https://github.com/ElmadaniS/halyn) | NRP control plane with domain-scoped authorization |
|
|
177
|
+
|
|
178
|
+
## Why Not Just Use MCP?
|
|
179
|
+
|
|
180
|
+
MCP is brilliant for software. But:
|
|
181
|
+
|
|
182
|
+
- MCP has no concept of **physical safety** (shield rules)
|
|
183
|
+
- MCP has no concept of **real-time events** (push, not pull)
|
|
184
|
+
- MCP has no concept of **self-describing hardware** (manifests)
|
|
185
|
+
- MCP has no concept of **universal device identity** (`nrp://`)
|
|
186
|
+
- MCP tools are defined by the server. NRP tools are declared by the device.
|
|
187
|
+
|
|
188
|
+
NRP complements MCP. A control plane exposes NRP nodes as MCP tools — transparent to the LLM.
|
|
189
|
+
|
|
190
|
+
## Architecture
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
Any LLM (Claude, GPT, Ollama, local)
|
|
194
|
+
│
|
|
195
|
+
│ MCP (software)
|
|
196
|
+
│
|
|
197
|
+
▼
|
|
198
|
+
Control Plane (e.g. Halyn)
|
|
199
|
+
│
|
|
200
|
+
│ NRP (physical + digital world)
|
|
201
|
+
│
|
|
202
|
+
├──→ Servers (SSH)
|
|
203
|
+
├──→ Robots (ROS2, Unitree, DJI)
|
|
204
|
+
├──→ Sensors (MQTT)
|
|
205
|
+
├──→ APIs (REST, GraphQL — auto-introspected)
|
|
206
|
+
├──→ Containers (Docker)
|
|
207
|
+
├──→ Browsers (Chrome CDP)
|
|
208
|
+
├──→ Factories (OPC-UA, Modbus)
|
|
209
|
+
├──→ Vehicles (CAN, DDS)
|
|
210
|
+
└──→ Anything with an interface
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). A driver is 4 methods, ~100 lines. If it has an interface, it can be an NRP node.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT — Free forever. Use it. Build on it.
|
|
220
|
+
|
|
221
|
+
## Author
|
|
222
|
+
|
|
223
|
+
**Elmadani SALKA**
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="logo.svg" width="80" alt="Halyn">
|
|
4
|
+
|
|
5
|
+
# NRP
|
|
6
|
+
|
|
7
|
+
### Node Reach Protocol
|
|
8
|
+
|
|
9
|
+
**The universal standard for AI-to-world control.**
|
|
10
|
+
|
|
11
|
+
MCP connects LLMs to software.<br>
|
|
12
|
+
**NRP connects LLMs to everything else.**
|
|
13
|
+
|
|
14
|
+
Servers · Robots · Drones · Sensors · Vehicles · APIs · Factories · Smart Homes
|
|
15
|
+
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
[](https://python.org)
|
|
18
|
+
[]()
|
|
19
|
+
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## The Problem
|
|
25
|
+
|
|
26
|
+
Every device speaks a different language. SSH for servers. ROS2 for robots. MQTT for sensors. OPC-UA for factories. REST for APIs. Hundreds of protocols. Thousands of SDKs. No standard.
|
|
27
|
+
|
|
28
|
+
MCP standardized software integration. NRP does the same for hardware and physical systems.
|
|
29
|
+
|
|
30
|
+
## The Protocol
|
|
31
|
+
|
|
32
|
+
3 methods. That is the interface.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
OBSERVE → read state (sensors, metrics, cameras, APIs)
|
|
36
|
+
ACT → change state (commands, movements, writes, calls)
|
|
37
|
+
SHIELD → safety limits (boundaries the AI cannot cross)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every device becomes a **node**. Every node has an address:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
nrp://factory/robot/arm-7
|
|
44
|
+
nrp://farm/sensor/soil-north
|
|
45
|
+
nrp://cloud/api/stripe
|
|
46
|
+
nrp://home/light/kitchen
|
|
47
|
+
nrp://fleet/vehicle/truck-42
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Every node **describes itself**. The AI reads the manifest and knows what to do. Zero configuration. Zero documentation.
|
|
51
|
+
|
|
52
|
+
## Write a Driver in 100 Lines
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from nrp import NRPDriver, NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
56
|
+
|
|
57
|
+
class MyRobot(NRPDriver):
|
|
58
|
+
|
|
59
|
+
def manifest(self) -> NRPManifest:
|
|
60
|
+
return NRPManifest(
|
|
61
|
+
nrp_id=self._nrp_id,
|
|
62
|
+
manufacturer="Unitree", model="G1",
|
|
63
|
+
observe=[
|
|
64
|
+
ChannelSpec("joints", "float[]", unit="rad", rate="100Hz"),
|
|
65
|
+
ChannelSpec("battery", "int", unit="percent"),
|
|
66
|
+
ChannelSpec("camera", "image", rate="30Hz"),
|
|
67
|
+
],
|
|
68
|
+
act=[
|
|
69
|
+
ActionSpec("walk", {"speed": "float m/s"}, "Walk forward", dangerous=True),
|
|
70
|
+
ActionSpec("pick", {"target": "string"}, "Pick an object", dangerous=True),
|
|
71
|
+
ActionSpec("stand", {}, "Stand still"),
|
|
72
|
+
],
|
|
73
|
+
shield=[
|
|
74
|
+
ShieldSpec("max_speed", "limit", 1.5, "m/s"),
|
|
75
|
+
ShieldSpec("workspace", "zone", [0, 0, 10, 10], "meters"),
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def observe(self, channels=None):
|
|
80
|
+
return {"joints": self.read_joints(), "battery": self.read_battery()}
|
|
81
|
+
|
|
82
|
+
async def act(self, command, args):
|
|
83
|
+
if command == "walk":
|
|
84
|
+
return self.walk(args["speed"])
|
|
85
|
+
if command == "pick":
|
|
86
|
+
return self.pick(args["target"])
|
|
87
|
+
|
|
88
|
+
def shield_rules(self):
|
|
89
|
+
return [ShieldRule("max_speed", ShieldType.LIMIT, 1.5)]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## What the AI Sees
|
|
95
|
+
|
|
96
|
+
When a node connects, the AI receives a human-readable description:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
Node: nrp://factory/robot/g1-01
|
|
100
|
+
Device: Unitree G1
|
|
101
|
+
Observe (read state):
|
|
102
|
+
joints: float[] (rad) — Joint angles at 100Hz
|
|
103
|
+
battery: int (percent) — Battery level
|
|
104
|
+
camera: image — Camera feed at 30Hz
|
|
105
|
+
Act (commands):
|
|
106
|
+
walk(speed: float m/s) [DANGEROUS] — Walk forward
|
|
107
|
+
pick(target: string) [DANGEROUS] — Pick an object
|
|
108
|
+
stand() — Stand still
|
|
109
|
+
Shield (safety limits):
|
|
110
|
+
max_speed: limit = 1.5 m/s
|
|
111
|
+
workspace: zone = [0, 0, 10, 10] meters
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
## Real-Time Events
|
|
117
|
+
|
|
118
|
+
Nodes push events. The AI reacts without polling.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# The node pushes
|
|
122
|
+
await driver.emit("battery_low", Severity.WARNING, percent=8)
|
|
123
|
+
await driver.emit("collision", Severity.EMERGENCY, force=45.2)
|
|
124
|
+
|
|
125
|
+
# The control plane routes
|
|
126
|
+
bus.subscribe("battery_*", alert_handler)
|
|
127
|
+
bus.subscribe("nrp://factory/*", factory_monitor)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Emergency events bypass all queues** and are processed synchronously.
|
|
131
|
+
|
|
132
|
+
## Specification
|
|
133
|
+
|
|
134
|
+
| Document | Description |
|
|
135
|
+
|----------|-------------|
|
|
136
|
+
| [IDENTITY.md](spec/IDENTITY.md) | Universal addressing: `nrp://scope/kind/name` |
|
|
137
|
+
| [MANIFEST.md](spec/MANIFEST.md) | Self-describing nodes: channels, actions, shields |
|
|
138
|
+
| [EVENTS.md](spec/EVENTS.md) | Real-time push: severity levels, emergency bypass |
|
|
139
|
+
| [NRP_SPEC.md](spec/NRP_SPEC.md) | Protocol overview |
|
|
140
|
+
|
|
141
|
+
## Install
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pip install nrp
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Examples
|
|
148
|
+
|
|
149
|
+
- [`hello_ssh.py`](examples/hello_ssh.py) — Connect to a server in 60 lines
|
|
150
|
+
- [`multi_node.py`](examples/multi_node.py) — 3 sensors, 1 conversation
|
|
151
|
+
|
|
152
|
+
## Built With NRP
|
|
153
|
+
|
|
154
|
+
| Project | Description |
|
|
155
|
+
|---------|-------------|
|
|
156
|
+
| [Halyn](https://github.com/ElmadaniS/halyn) | NRP control plane with domain-scoped authorization |
|
|
157
|
+
|
|
158
|
+
## Why Not Just Use MCP?
|
|
159
|
+
|
|
160
|
+
MCP is brilliant for software. But:
|
|
161
|
+
|
|
162
|
+
- MCP has no concept of **physical safety** (shield rules)
|
|
163
|
+
- MCP has no concept of **real-time events** (push, not pull)
|
|
164
|
+
- MCP has no concept of **self-describing hardware** (manifests)
|
|
165
|
+
- MCP has no concept of **universal device identity** (`nrp://`)
|
|
166
|
+
- MCP tools are defined by the server. NRP tools are declared by the device.
|
|
167
|
+
|
|
168
|
+
NRP complements MCP. A control plane exposes NRP nodes as MCP tools — transparent to the LLM.
|
|
169
|
+
|
|
170
|
+
## Architecture
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
Any LLM (Claude, GPT, Ollama, local)
|
|
174
|
+
│
|
|
175
|
+
│ MCP (software)
|
|
176
|
+
│
|
|
177
|
+
▼
|
|
178
|
+
Control Plane (e.g. Halyn)
|
|
179
|
+
│
|
|
180
|
+
│ NRP (physical + digital world)
|
|
181
|
+
│
|
|
182
|
+
├──→ Servers (SSH)
|
|
183
|
+
├──→ Robots (ROS2, Unitree, DJI)
|
|
184
|
+
├──→ Sensors (MQTT)
|
|
185
|
+
├──→ APIs (REST, GraphQL — auto-introspected)
|
|
186
|
+
├──→ Containers (Docker)
|
|
187
|
+
├──→ Browsers (Chrome CDP)
|
|
188
|
+
├──→ Factories (OPC-UA, Modbus)
|
|
189
|
+
├──→ Vehicles (CAN, DDS)
|
|
190
|
+
└──→ Anything with an interface
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Contributing
|
|
194
|
+
|
|
195
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). A driver is 4 methods, ~100 lines. If it has an interface, it can be an NRP node.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT — Free forever. Use it. Build on it.
|
|
200
|
+
|
|
201
|
+
## Author
|
|
202
|
+
|
|
203
|
+
**Elmadani SALKA**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nrprotocol"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Node Reach Protocol — The universal standard for AI-to-world control."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
authors = [{name = "Elmadani SALKA", email = "Elmadani.SALKA@proton.me"}]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
keywords = ["ai", "robotics", "iot", "protocol", "mcp", "nrp", "llm", "edge"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
15
|
+
"Topic :: System :: Networking",
|
|
16
|
+
]
|
|
17
|
+
dependencies = []
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/ElmadaniS/nrp"
|
|
21
|
+
Repository = "https://github.com/ElmadaniS/nrp"
|
|
22
|
+
Issues = "https://github.com/ElmadaniS/nrp/issues"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["sdk/python"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=68"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NRP — Node Reach Protocol
|
|
3
|
+
|
|
4
|
+
The universal protocol for AI-to-world control.
|
|
5
|
+
MCP connects LLMs to software. NRP connects LLMs to everything else.
|
|
6
|
+
|
|
7
|
+
from nrp import NRPDriver, NRPId, NRPManifest, EventBus
|
|
8
|
+
|
|
9
|
+
https://github.com/navigia/nrp
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .identity import NRPId
|
|
13
|
+
from .manifest import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
14
|
+
from .events import NRPEvent, EventBus, EventSSE, Severity
|
|
15
|
+
from .driver import NRPDriver, ShieldRule, ShieldType
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__author__ = "Elmadani SALKA"
|
|
19
|
+
__license__ = "MIT"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"NRPId",
|
|
23
|
+
"NRPManifest", "ChannelSpec", "ActionSpec", "ShieldSpec",
|
|
24
|
+
"NRPEvent", "EventBus", "EventSSE", "Severity",
|
|
25
|
+
"NRPDriver", "ShieldRule", "ShieldType",
|
|
26
|
+
]
|
|
27
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Driver v2 — Universal interface with Manifest + Events.
|
|
5
|
+
|
|
6
|
+
Every device implements:
|
|
7
|
+
manifest() — declare what you are and what you can do
|
|
8
|
+
observe() — read state
|
|
9
|
+
act() — change state
|
|
10
|
+
on_event() — push events to the control plane
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Callable, Awaitable
|
|
19
|
+
|
|
20
|
+
from .identity import NRPId
|
|
21
|
+
from .manifest import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
22
|
+
from .events import NRPEvent, EventBus, Severity
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ShieldType(str, Enum):
|
|
26
|
+
LIMIT = "limit"
|
|
27
|
+
ZONE = "zone"
|
|
28
|
+
THRESHOLD = "threshold"
|
|
29
|
+
PATTERN = "pattern"
|
|
30
|
+
COMMAND = "command"
|
|
31
|
+
CONFIRM = "confirm"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ShieldRule:
|
|
36
|
+
name: str
|
|
37
|
+
type: ShieldType
|
|
38
|
+
value: Any = None
|
|
39
|
+
unit: str = ""
|
|
40
|
+
description: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NRPDriver(ABC):
|
|
44
|
+
"""
|
|
45
|
+
Base class for all NRP drivers.
|
|
46
|
+
|
|
47
|
+
Implement this to connect ANY device to AI.
|
|
48
|
+
Subclass and implement manifest(), observe(), act(), shield_rules().
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._event_bus: EventBus | None = None
|
|
53
|
+
self._nrp_id: NRPId | None = None
|
|
54
|
+
|
|
55
|
+
def bind(self, nrp_id: NRPId, event_bus: EventBus) -> None:
|
|
56
|
+
"""Called by NRP Bridge at registration. Gives driver access to events."""
|
|
57
|
+
self._nrp_id = nrp_id
|
|
58
|
+
self._event_bus = event_bus
|
|
59
|
+
|
|
60
|
+
# ─── The 4 core methods ─────────────────────────
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def manifest(self) -> NRPManifest:
|
|
64
|
+
"""Declare everything about this node. Called once at registration."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Read node state. Returns {channel_name: data}."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def act(self, command: str, args: dict[str, Any]) -> Any:
|
|
72
|
+
"""Execute a command. Returns result."""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def shield_rules(self) -> list[ShieldRule]:
|
|
76
|
+
"""Safety limits. Enforced by control plane."""
|
|
77
|
+
|
|
78
|
+
# ─── Events ─────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async def emit(self, name: str, severity: str = Severity.INFO, **data: Any) -> None:
|
|
81
|
+
"""Push an event to the control plane."""
|
|
82
|
+
if self._event_bus and self._nrp_id:
|
|
83
|
+
event = NRPEvent(
|
|
84
|
+
source=self._nrp_id.uri,
|
|
85
|
+
name=name,
|
|
86
|
+
severity=severity,
|
|
87
|
+
data=data,
|
|
88
|
+
)
|
|
89
|
+
await self._event_bus.emit(event)
|
|
90
|
+
|
|
91
|
+
async def emit_emergency(self, name: str, **data: Any) -> None:
|
|
92
|
+
"""Push an emergency event. Bypasses all queues."""
|
|
93
|
+
await self.emit(name, Severity.EMERGENCY, **data)
|
|
94
|
+
|
|
95
|
+
# ─── Lifecycle ──────────────────────────────────
|
|
96
|
+
|
|
97
|
+
async def connect(self) -> bool:
|
|
98
|
+
"""Establish connection. Override for networked devices."""
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
async def disconnect(self) -> None:
|
|
102
|
+
"""Clean shutdown."""
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
async def heartbeat(self) -> dict[str, Any]:
|
|
106
|
+
"""Health check."""
|
|
107
|
+
try:
|
|
108
|
+
state = await self.observe(["status"])
|
|
109
|
+
return {"alive": True, **state}
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return {"alive": False, "error": str(e)[:200]}
|
|
112
|
+
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Events — Asynchronous event bus with severity-based routing.
|
|
5
|
+
|
|
6
|
+
Nodes emit events. The control plane dispatches to subscribers
|
|
7
|
+
based on pattern matching. Emergency events bypass the queue.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Callable, Awaitable
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("jarvis.events")
|
|
21
|
+
|
|
22
|
+
EventHandler = Callable[["NRPEvent"], Awaitable[None] | None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Severity:
|
|
26
|
+
"""Event severity levels."""
|
|
27
|
+
DEBUG = "debug"
|
|
28
|
+
INFO = "info"
|
|
29
|
+
WARNING = "warning"
|
|
30
|
+
CRITICAL = "critical"
|
|
31
|
+
EMERGENCY = "emergency" # Bypasses all queues
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class NRPEvent:
|
|
36
|
+
"""Single event payload."""
|
|
37
|
+
source: str # NRP ID or node name
|
|
38
|
+
name: str # "temperature_changed", "battery_low", "collision"
|
|
39
|
+
severity: str # Severity level
|
|
40
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
timestamp: float = field(default_factory=time.time)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"source": self.source,
|
|
46
|
+
"name": self.name,
|
|
47
|
+
"severity": self.severity,
|
|
48
|
+
"data": self.data,
|
|
49
|
+
"timestamp": self.timestamp,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> str:
|
|
53
|
+
return json.dumps(self.to_dict(), default=str, ensure_ascii=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EventBus:
|
|
57
|
+
"""Central event routing. Subscribe by pattern, receive matching events."""
|
|
58
|
+
|
|
59
|
+
__slots__ = ("_handlers", "_history", "_max_history", "_queue")
|
|
60
|
+
|
|
61
|
+
def __init__(self, max_history: int = 10_000) -> None:
|
|
62
|
+
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
|
63
|
+
self._history: list[NRPEvent] = []
|
|
64
|
+
self._max_history = max_history
|
|
65
|
+
self._queue: asyncio.Queue[NRPEvent] = asyncio.Queue(maxsize=50_000)
|
|
66
|
+
|
|
67
|
+
def subscribe(self, pattern: str, handler: EventHandler) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Subscribe to events matching a pattern.
|
|
70
|
+
|
|
71
|
+
Patterns:
|
|
72
|
+
"*" — all events
|
|
73
|
+
"battery_low" — exact event name
|
|
74
|
+
"nrp://farm/*" — all events from farm scope
|
|
75
|
+
"temperature_*" — wildcard on event name
|
|
76
|
+
"""
|
|
77
|
+
self._handlers[pattern].append(handler)
|
|
78
|
+
log.debug("events.subscribe pattern=%s", pattern)
|
|
79
|
+
|
|
80
|
+
def unsubscribe(self, pattern: str, handler: EventHandler) -> None:
|
|
81
|
+
if pattern in self._handlers:
|
|
82
|
+
self._handlers[pattern] = [h for h in self._handlers[pattern] if h is not handler]
|
|
83
|
+
|
|
84
|
+
async def emit(self, event: NRPEvent) -> int:
|
|
85
|
+
"""
|
|
86
|
+
Emit an event. Routes to all matching handlers.
|
|
87
|
+
Returns number of handlers that received it.
|
|
88
|
+
|
|
89
|
+
EMERGENCY events are processed synchronously (no queue).
|
|
90
|
+
All others go through the async queue.
|
|
91
|
+
"""
|
|
92
|
+
self._record(event)
|
|
93
|
+
|
|
94
|
+
if event.severity == Severity.EMERGENCY:
|
|
95
|
+
return await self._dispatch_now(event)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
self._queue.put_nowait(event)
|
|
99
|
+
except asyncio.QueueFull:
|
|
100
|
+
log.error("events.queue_full dropping=%s", event.name)
|
|
101
|
+
return 0 # Will be dispatched by process_loop
|
|
102
|
+
|
|
103
|
+
async def emit_simple(
|
|
104
|
+
self, source: str, name: str, severity: str = Severity.INFO, **data: Any
|
|
105
|
+
) -> int:
|
|
106
|
+
"""Shorthand for emitting events."""
|
|
107
|
+
return await self.emit(NRPEvent(source=source, name=name, severity=severity, data=data))
|
|
108
|
+
|
|
109
|
+
async def process_loop(self) -> None:
|
|
110
|
+
"""Background loop that processes queued events. Run as asyncio task."""
|
|
111
|
+
log.info("events.loop_started")
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
event = await asyncio.wait_for(self._queue.get(), timeout=1.0)
|
|
115
|
+
await self._dispatch_now(event)
|
|
116
|
+
except asyncio.TimeoutError:
|
|
117
|
+
continue
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
log.exception("events.loop_error: %s", exc)
|
|
120
|
+
|
|
121
|
+
def recent(self, n: int = 50, source: str = "", name: str = "", severity: str = "") -> list[NRPEvent]:
|
|
122
|
+
"""Query recent events with optional filters."""
|
|
123
|
+
events = self._history
|
|
124
|
+
if source:
|
|
125
|
+
events = [e for e in events if source in e.source]
|
|
126
|
+
if name:
|
|
127
|
+
events = [e for e in events if name in e.name]
|
|
128
|
+
if severity:
|
|
129
|
+
events = [e for e in events if e.severity == severity]
|
|
130
|
+
return events[-n:]
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def pending(self) -> int:
|
|
134
|
+
return self._queue.qsize()
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def total(self) -> int:
|
|
138
|
+
return len(self._history)
|
|
139
|
+
|
|
140
|
+
# ─── Internal ──────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async def _dispatch_now(self, event: NRPEvent) -> int:
|
|
143
|
+
"""Dispatch to all matching handlers immediately."""
|
|
144
|
+
import fnmatch
|
|
145
|
+
count = 0
|
|
146
|
+
for pattern, handlers in self._handlers.items():
|
|
147
|
+
matched = (
|
|
148
|
+
pattern == "*"
|
|
149
|
+
or pattern == event.name
|
|
150
|
+
or fnmatch.fnmatch(event.name, pattern)
|
|
151
|
+
or fnmatch.fnmatch(event.source, pattern)
|
|
152
|
+
)
|
|
153
|
+
if matched:
|
|
154
|
+
for handler in handlers:
|
|
155
|
+
try:
|
|
156
|
+
result = handler(event)
|
|
157
|
+
if asyncio.iscoroutine(result):
|
|
158
|
+
await result
|
|
159
|
+
count += 1
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
log.error("events.handler_error pattern=%s error=%s", pattern, exc)
|
|
162
|
+
return count
|
|
163
|
+
|
|
164
|
+
def _record(self, event: NRPEvent) -> None:
|
|
165
|
+
"""Store in history ring buffer."""
|
|
166
|
+
self._history.append(event)
|
|
167
|
+
if len(self._history) > self._max_history:
|
|
168
|
+
self._history = self._history[-self._max_history // 2:]
|
|
169
|
+
|
|
170
|
+
lvl = {
|
|
171
|
+
Severity.DEBUG: logging.DEBUG,
|
|
172
|
+
Severity.INFO: logging.INFO,
|
|
173
|
+
Severity.WARNING: logging.WARNING,
|
|
174
|
+
Severity.CRITICAL: logging.ERROR,
|
|
175
|
+
Severity.EMERGENCY: logging.CRITICAL,
|
|
176
|
+
}.get(event.severity, logging.INFO)
|
|
177
|
+
log.log(lvl, "event.%s source=%s data=%s", event.name, event.source,
|
|
178
|
+
json.dumps(event.data, default=str)[:200])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class EventSSE:
|
|
182
|
+
"""Server-Sent Events endpoint. Streams events to HTTP clients in real-time."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, bus: EventBus) -> None:
|
|
185
|
+
self.bus = bus
|
|
186
|
+
self._clients: list[asyncio.Queue[str]] = []
|
|
187
|
+
|
|
188
|
+
async def handler(self, request: Any) -> Any:
|
|
189
|
+
"""aiohttp SSE handler. Each client gets a queue."""
|
|
190
|
+
from aiohttp import web
|
|
191
|
+
from aiohttp.web import StreamResponse
|
|
192
|
+
|
|
193
|
+
response = StreamResponse()
|
|
194
|
+
response.content_type = "text/event-stream"
|
|
195
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
196
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
197
|
+
await response.prepare(request)
|
|
198
|
+
|
|
199
|
+
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1000)
|
|
200
|
+
self._clients.append(queue)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
while True:
|
|
204
|
+
try:
|
|
205
|
+
data = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
206
|
+
await response.write(f"data: {data}\n\n".encode())
|
|
207
|
+
except asyncio.TimeoutError:
|
|
208
|
+
# Keepalive
|
|
209
|
+
await response.write(b": keepalive\n\n")
|
|
210
|
+
except (ConnectionResetError, asyncio.CancelledError):
|
|
211
|
+
pass
|
|
212
|
+
finally:
|
|
213
|
+
self._clients.remove(queue)
|
|
214
|
+
|
|
215
|
+
return response
|
|
216
|
+
|
|
217
|
+
async def broadcast(self, event: NRPEvent) -> None:
|
|
218
|
+
"""Send event to all SSE clients."""
|
|
219
|
+
data = event.to_json()
|
|
220
|
+
dead: list[asyncio.Queue[str]] = []
|
|
221
|
+
for client in self._clients:
|
|
222
|
+
try:
|
|
223
|
+
client.put_nowait(data)
|
|
224
|
+
except asyncio.QueueFull:
|
|
225
|
+
dead.append(client)
|
|
226
|
+
for d in dead:
|
|
227
|
+
self._clients.remove(d)
|
|
228
|
+
|
|
229
|
+
def wire(self, bus: EventBus) -> None:
|
|
230
|
+
"""Subscribe to all events and broadcast to SSE clients."""
|
|
231
|
+
async def _forward(event: NRPEvent) -> None:
|
|
232
|
+
await self.broadcast(event)
|
|
233
|
+
bus.subscribe("*", _forward)
|
|
234
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Identity — Universal node addressing.
|
|
5
|
+
|
|
6
|
+
Stable addressing: nrp://scope/kind/name
|
|
7
|
+
Survives IP changes, network moves, device replacement.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
# nrp://factory-3/robot/arm-7
|
|
16
|
+
# nrp://home/sensor/kitchen-temp
|
|
17
|
+
# nrp://fleet/vehicle/truck-42
|
|
18
|
+
# nrp://farm/drone/survey-1
|
|
19
|
+
_NRP_PATTERN = re.compile(
|
|
20
|
+
r"^nrp://(?P<scope>[a-z0-9][a-z0-9._-]*)/"
|
|
21
|
+
r"(?P<kind>[a-z0-9][a-z0-9_-]*)/"
|
|
22
|
+
r"(?P<name>[a-z0-9][a-z0-9._-]*)$"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class NRPId:
|
|
28
|
+
"""Universal node identifier."""
|
|
29
|
+
scope: str # Location/org: "factory-3", "home", "fleet", "farm"
|
|
30
|
+
kind: str # Type: "robot", "sensor", "server", "vehicle", "drone"
|
|
31
|
+
name: str # Instance: "arm-7", "kitchen-temp", "truck-42"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def uri(self) -> str:
|
|
35
|
+
return f"nrp://{self.scope}/{self.kind}/{self.name}"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def short(self) -> str:
|
|
39
|
+
"""Short form for display: kind/name."""
|
|
40
|
+
return f"{self.kind}/{self.name}"
|
|
41
|
+
|
|
42
|
+
def matches(self, pattern: str) -> bool:
|
|
43
|
+
"""Match against glob patterns. E.g. 'factory-*/robot/*'."""
|
|
44
|
+
import fnmatch
|
|
45
|
+
return fnmatch.fnmatch(self.uri, f"nrp://{pattern}")
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def parse(cls, uri: str) -> NRPId:
|
|
49
|
+
"""Parse 'nrp://scope/kind/name' into NRPId."""
|
|
50
|
+
m = _NRP_PATTERN.match(uri.lower().strip())
|
|
51
|
+
if not m:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Invalid NRP ID: {uri!r}. "
|
|
54
|
+
f"Format: nrp://scope/kind/name (lowercase, alphanumeric, hyphens)"
|
|
55
|
+
)
|
|
56
|
+
return cls(scope=m["scope"], kind=m["kind"], name=m["name"])
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def create(cls, scope: str, kind: str, name: str) -> NRPId:
|
|
60
|
+
"""Create and validate an NRP ID."""
|
|
61
|
+
nid = cls(scope=scope.lower(), kind=kind.lower(), name=name.lower())
|
|
62
|
+
# Validate by roundtripping through parse
|
|
63
|
+
NRPId.parse(nid.uri)
|
|
64
|
+
return nid
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
return self.uri
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return f"NRPId({self.uri!r})"
|
|
71
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Manifest — Self-describing nodes.
|
|
5
|
+
|
|
6
|
+
Structured capability declaration for NRP nodes.
|
|
7
|
+
Channels (observe), actions (act), and constraints (shield)
|
|
8
|
+
are declared once and consumed by any control plane.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .identity import NRPId
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class ChannelSpec:
|
|
23
|
+
"""One observable channel (sensor, metric, state)."""
|
|
24
|
+
name: str
|
|
25
|
+
type: str # "float", "int", "bool", "string", "float[]", "image", "json"
|
|
26
|
+
unit: str = "" # "°C", "rad", "m/s", "percent", "Pa", ""
|
|
27
|
+
rate: str = "" # "100Hz", "1Hz", "on_change", "on_request"
|
|
28
|
+
description: str = ""
|
|
29
|
+
range: list[float] | None = None # [min, max] if applicable
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
d: dict[str, Any] = {"name": self.name, "type": self.type}
|
|
33
|
+
if self.unit: d["unit"] = self.unit
|
|
34
|
+
if self.rate: d["rate"] = self.rate
|
|
35
|
+
if self.description: d["description"] = self.description
|
|
36
|
+
if self.range: d["range"] = self.range
|
|
37
|
+
return d
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class ActionSpec:
|
|
42
|
+
"""One action the node can perform."""
|
|
43
|
+
name: str
|
|
44
|
+
args: dict[str, str] = field(default_factory=dict) # arg_name -> "type description"
|
|
45
|
+
description: str = ""
|
|
46
|
+
dangerous: bool = False
|
|
47
|
+
priority: str = "normal" # "normal", "high", "critical"
|
|
48
|
+
returns: str = "" # What it returns
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
d: dict[str, Any] = {"name": self.name}
|
|
52
|
+
if self.args: d["args"] = self.args
|
|
53
|
+
if self.description: d["description"] = self.description
|
|
54
|
+
if self.dangerous: d["dangerous"] = True
|
|
55
|
+
if self.priority != "normal": d["priority"] = self.priority
|
|
56
|
+
if self.returns: d["returns"] = self.returns
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class ShieldSpec:
|
|
62
|
+
"""One safety constraint."""
|
|
63
|
+
name: str
|
|
64
|
+
type: str # "limit", "zone", "threshold", "pattern", "confirm"
|
|
65
|
+
value: Any = None
|
|
66
|
+
unit: str = ""
|
|
67
|
+
description: str = ""
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
d: dict[str, Any] = {"name": self.name, "type": self.type}
|
|
71
|
+
if self.value is not None: d["value"] = self.value
|
|
72
|
+
if self.unit: d["unit"] = self.unit
|
|
73
|
+
if self.description: d["description"] = self.description
|
|
74
|
+
return d
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class NRPManifest:
|
|
79
|
+
"""Complete capability descriptor for one NRP node."""
|
|
80
|
+
|
|
81
|
+
# Identity
|
|
82
|
+
nrp_id: NRPId
|
|
83
|
+
manufacturer: str = ""
|
|
84
|
+
model: str = ""
|
|
85
|
+
firmware: str = ""
|
|
86
|
+
|
|
87
|
+
# Capabilities
|
|
88
|
+
observe: list[ChannelSpec] = field(default_factory=list)
|
|
89
|
+
act: list[ActionSpec] = field(default_factory=list)
|
|
90
|
+
shield: list[ShieldSpec] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
# Metadata
|
|
93
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
94
|
+
registered_at: float = field(default_factory=time.time)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"nrp_id": self.nrp_id.uri,
|
|
99
|
+
"manufacturer": self.manufacturer,
|
|
100
|
+
"model": self.model,
|
|
101
|
+
"firmware": self.firmware,
|
|
102
|
+
"observe": [c.to_dict() for c in self.observe],
|
|
103
|
+
"act": [a.to_dict() for a in self.act],
|
|
104
|
+
"shield": [s.to_dict() for s in self.shield],
|
|
105
|
+
"tags": self.tags,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def to_json(self, indent: int = 2) -> str:
|
|
109
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
110
|
+
|
|
111
|
+
def to_llm_description(self) -> str:
|
|
112
|
+
"""Structured text summary suitable for LLM context injection."""
|
|
113
|
+
lines = [f"Node: {self.nrp_id.uri}"]
|
|
114
|
+
if self.manufacturer:
|
|
115
|
+
lines.append(f" Device: {self.manufacturer} {self.model}")
|
|
116
|
+
|
|
117
|
+
if self.observe:
|
|
118
|
+
lines.append(" Observe (read state):")
|
|
119
|
+
for ch in self.observe:
|
|
120
|
+
unit = f" ({ch.unit})" if ch.unit else ""
|
|
121
|
+
desc = f" — {ch.description}" if ch.description else ""
|
|
122
|
+
lines.append(f" {ch.name}: {ch.type}{unit}{desc}")
|
|
123
|
+
|
|
124
|
+
if self.act:
|
|
125
|
+
lines.append(" Act (commands):")
|
|
126
|
+
for a in self.act:
|
|
127
|
+
args_str = ", ".join(f"{k}: {v}" for k, v in a.args.items()) if a.args else "none"
|
|
128
|
+
danger = " [DANGEROUS]" if a.dangerous else ""
|
|
129
|
+
prio = f" [PRIORITY={a.priority}]" if a.priority != "normal" else ""
|
|
130
|
+
desc = f" — {a.description}" if a.description else ""
|
|
131
|
+
lines.append(f" {a.name}({args_str}){danger}{prio}{desc}")
|
|
132
|
+
|
|
133
|
+
if self.shield:
|
|
134
|
+
lines.append(" Shield (safety limits):")
|
|
135
|
+
for s in self.shield:
|
|
136
|
+
unit = f" {s.unit}" if s.unit else ""
|
|
137
|
+
lines.append(f" {s.name}: {s.type} = {s.value}{unit}")
|
|
138
|
+
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_dict(cls, data: dict[str, Any]) -> NRPManifest:
|
|
143
|
+
"""Parse a manifest from JSON/dict."""
|
|
144
|
+
nrp_id = NRPId.parse(data["nrp_id"])
|
|
145
|
+
return cls(
|
|
146
|
+
nrp_id=nrp_id,
|
|
147
|
+
manufacturer=data.get("manufacturer", ""),
|
|
148
|
+
model=data.get("model", ""),
|
|
149
|
+
firmware=data.get("firmware", ""),
|
|
150
|
+
observe=[ChannelSpec(**c) for c in data.get("observe", [])],
|
|
151
|
+
act=[ActionSpec(
|
|
152
|
+
name=a["name"],
|
|
153
|
+
args=a.get("args", {}),
|
|
154
|
+
description=a.get("description", ""),
|
|
155
|
+
dangerous=a.get("dangerous", False),
|
|
156
|
+
priority=a.get("priority", "normal"),
|
|
157
|
+
returns=a.get("returns", ""),
|
|
158
|
+
) for a in data.get("act", [])],
|
|
159
|
+
shield=[ShieldSpec(**s) for s in data.get("shield", [])],
|
|
160
|
+
tags=data.get("tags", {}),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_json(cls, text: str) -> NRPManifest:
|
|
165
|
+
return cls.from_dict(json.loads(text))
|
|
166
|
+
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nrprotocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Node Reach Protocol — The universal standard for AI-to-world control.
|
|
5
|
+
Author-email: Elmadani SALKA <Elmadani.SALKA@proton.me>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ElmadaniS/nrp
|
|
8
|
+
Project-URL: Repository, https://github.com/ElmadaniS/nrp
|
|
9
|
+
Project-URL: Issues, https://github.com/ElmadaniS/nrp/issues
|
|
10
|
+
Keywords: ai,robotics,iot,protocol,mcp,nrp,llm,edge
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Classifier: Topic :: System :: Networking
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
<div align="center">
|
|
22
|
+
|
|
23
|
+
<img src="logo.svg" width="80" alt="Halyn">
|
|
24
|
+
|
|
25
|
+
# NRP
|
|
26
|
+
|
|
27
|
+
### Node Reach Protocol
|
|
28
|
+
|
|
29
|
+
**The universal standard for AI-to-world control.**
|
|
30
|
+
|
|
31
|
+
MCP connects LLMs to software.<br>
|
|
32
|
+
**NRP connects LLMs to everything else.**
|
|
33
|
+
|
|
34
|
+
Servers · Robots · Drones · Sensors · Vehicles · APIs · Factories · Smart Homes
|
|
35
|
+
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://python.org)
|
|
38
|
+
[]()
|
|
39
|
+
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## The Problem
|
|
45
|
+
|
|
46
|
+
Every device speaks a different language. SSH for servers. ROS2 for robots. MQTT for sensors. OPC-UA for factories. REST for APIs. Hundreds of protocols. Thousands of SDKs. No standard.
|
|
47
|
+
|
|
48
|
+
MCP standardized software integration. NRP does the same for hardware and physical systems.
|
|
49
|
+
|
|
50
|
+
## The Protocol
|
|
51
|
+
|
|
52
|
+
3 methods. That is the interface.
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
OBSERVE → read state (sensors, metrics, cameras, APIs)
|
|
56
|
+
ACT → change state (commands, movements, writes, calls)
|
|
57
|
+
SHIELD → safety limits (boundaries the AI cannot cross)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every device becomes a **node**. Every node has an address:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
nrp://factory/robot/arm-7
|
|
64
|
+
nrp://farm/sensor/soil-north
|
|
65
|
+
nrp://cloud/api/stripe
|
|
66
|
+
nrp://home/light/kitchen
|
|
67
|
+
nrp://fleet/vehicle/truck-42
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every node **describes itself**. The AI reads the manifest and knows what to do. Zero configuration. Zero documentation.
|
|
71
|
+
|
|
72
|
+
## Write a Driver in 100 Lines
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from nrp import NRPDriver, NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
76
|
+
|
|
77
|
+
class MyRobot(NRPDriver):
|
|
78
|
+
|
|
79
|
+
def manifest(self) -> NRPManifest:
|
|
80
|
+
return NRPManifest(
|
|
81
|
+
nrp_id=self._nrp_id,
|
|
82
|
+
manufacturer="Unitree", model="G1",
|
|
83
|
+
observe=[
|
|
84
|
+
ChannelSpec("joints", "float[]", unit="rad", rate="100Hz"),
|
|
85
|
+
ChannelSpec("battery", "int", unit="percent"),
|
|
86
|
+
ChannelSpec("camera", "image", rate="30Hz"),
|
|
87
|
+
],
|
|
88
|
+
act=[
|
|
89
|
+
ActionSpec("walk", {"speed": "float m/s"}, "Walk forward", dangerous=True),
|
|
90
|
+
ActionSpec("pick", {"target": "string"}, "Pick an object", dangerous=True),
|
|
91
|
+
ActionSpec("stand", {}, "Stand still"),
|
|
92
|
+
],
|
|
93
|
+
shield=[
|
|
94
|
+
ShieldSpec("max_speed", "limit", 1.5, "m/s"),
|
|
95
|
+
ShieldSpec("workspace", "zone", [0, 0, 10, 10], "meters"),
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def observe(self, channels=None):
|
|
100
|
+
return {"joints": self.read_joints(), "battery": self.read_battery()}
|
|
101
|
+
|
|
102
|
+
async def act(self, command, args):
|
|
103
|
+
if command == "walk":
|
|
104
|
+
return self.walk(args["speed"])
|
|
105
|
+
if command == "pick":
|
|
106
|
+
return self.pick(args["target"])
|
|
107
|
+
|
|
108
|
+
def shield_rules(self):
|
|
109
|
+
return [ShieldRule("max_speed", ShieldType.LIMIT, 1.5)]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
## What the AI Sees
|
|
115
|
+
|
|
116
|
+
When a node connects, the AI receives a human-readable description:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Node: nrp://factory/robot/g1-01
|
|
120
|
+
Device: Unitree G1
|
|
121
|
+
Observe (read state):
|
|
122
|
+
joints: float[] (rad) — Joint angles at 100Hz
|
|
123
|
+
battery: int (percent) — Battery level
|
|
124
|
+
camera: image — Camera feed at 30Hz
|
|
125
|
+
Act (commands):
|
|
126
|
+
walk(speed: float m/s) [DANGEROUS] — Walk forward
|
|
127
|
+
pick(target: string) [DANGEROUS] — Pick an object
|
|
128
|
+
stand() — Stand still
|
|
129
|
+
Shield (safety limits):
|
|
130
|
+
max_speed: limit = 1.5 m/s
|
|
131
|
+
workspace: zone = [0, 0, 10, 10] meters
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
## Real-Time Events
|
|
137
|
+
|
|
138
|
+
Nodes push events. The AI reacts without polling.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# The node pushes
|
|
142
|
+
await driver.emit("battery_low", Severity.WARNING, percent=8)
|
|
143
|
+
await driver.emit("collision", Severity.EMERGENCY, force=45.2)
|
|
144
|
+
|
|
145
|
+
# The control plane routes
|
|
146
|
+
bus.subscribe("battery_*", alert_handler)
|
|
147
|
+
bus.subscribe("nrp://factory/*", factory_monitor)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Emergency events bypass all queues** and are processed synchronously.
|
|
151
|
+
|
|
152
|
+
## Specification
|
|
153
|
+
|
|
154
|
+
| Document | Description |
|
|
155
|
+
|----------|-------------|
|
|
156
|
+
| [IDENTITY.md](spec/IDENTITY.md) | Universal addressing: `nrp://scope/kind/name` |
|
|
157
|
+
| [MANIFEST.md](spec/MANIFEST.md) | Self-describing nodes: channels, actions, shields |
|
|
158
|
+
| [EVENTS.md](spec/EVENTS.md) | Real-time push: severity levels, emergency bypass |
|
|
159
|
+
| [NRP_SPEC.md](spec/NRP_SPEC.md) | Protocol overview |
|
|
160
|
+
|
|
161
|
+
## Install
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install nrp
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Examples
|
|
168
|
+
|
|
169
|
+
- [`hello_ssh.py`](examples/hello_ssh.py) — Connect to a server in 60 lines
|
|
170
|
+
- [`multi_node.py`](examples/multi_node.py) — 3 sensors, 1 conversation
|
|
171
|
+
|
|
172
|
+
## Built With NRP
|
|
173
|
+
|
|
174
|
+
| Project | Description |
|
|
175
|
+
|---------|-------------|
|
|
176
|
+
| [Halyn](https://github.com/ElmadaniS/halyn) | NRP control plane with domain-scoped authorization |
|
|
177
|
+
|
|
178
|
+
## Why Not Just Use MCP?
|
|
179
|
+
|
|
180
|
+
MCP is brilliant for software. But:
|
|
181
|
+
|
|
182
|
+
- MCP has no concept of **physical safety** (shield rules)
|
|
183
|
+
- MCP has no concept of **real-time events** (push, not pull)
|
|
184
|
+
- MCP has no concept of **self-describing hardware** (manifests)
|
|
185
|
+
- MCP has no concept of **universal device identity** (`nrp://`)
|
|
186
|
+
- MCP tools are defined by the server. NRP tools are declared by the device.
|
|
187
|
+
|
|
188
|
+
NRP complements MCP. A control plane exposes NRP nodes as MCP tools — transparent to the LLM.
|
|
189
|
+
|
|
190
|
+
## Architecture
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
Any LLM (Claude, GPT, Ollama, local)
|
|
194
|
+
│
|
|
195
|
+
│ MCP (software)
|
|
196
|
+
│
|
|
197
|
+
▼
|
|
198
|
+
Control Plane (e.g. Halyn)
|
|
199
|
+
│
|
|
200
|
+
│ NRP (physical + digital world)
|
|
201
|
+
│
|
|
202
|
+
├──→ Servers (SSH)
|
|
203
|
+
├──→ Robots (ROS2, Unitree, DJI)
|
|
204
|
+
├──→ Sensors (MQTT)
|
|
205
|
+
├──→ APIs (REST, GraphQL — auto-introspected)
|
|
206
|
+
├──→ Containers (Docker)
|
|
207
|
+
├──→ Browsers (Chrome CDP)
|
|
208
|
+
├──→ Factories (OPC-UA, Modbus)
|
|
209
|
+
├──→ Vehicles (CAN, DDS)
|
|
210
|
+
└──→ Anything with an interface
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). A driver is 4 methods, ~100 lines. If it has an interface, it can be an NRP node.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT — Free forever. Use it. Build on it.
|
|
220
|
+
|
|
221
|
+
## Author
|
|
222
|
+
|
|
223
|
+
**Elmadani SALKA**
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
sdk/python/nrp/__init__.py
|
|
5
|
+
sdk/python/nrp/driver.py
|
|
6
|
+
sdk/python/nrp/events.py
|
|
7
|
+
sdk/python/nrp/identity.py
|
|
8
|
+
sdk/python/nrp/manifest.py
|
|
9
|
+
sdk/python/nrp/py.typed
|
|
10
|
+
sdk/python/nrprotocol.egg-info/PKG-INFO
|
|
11
|
+
sdk/python/nrprotocol.egg-info/SOURCES.txt
|
|
12
|
+
sdk/python/nrprotocol.egg-info/dependency_links.txt
|
|
13
|
+
sdk/python/nrprotocol.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nrp
|